Skip to content

v4: the 4.0 core, surface and alpha branch (tracking) - #3262

Draft
mgravell wants to merge 848 commits into
mainfrom
v4
Draft

mgravell wants to merge 848 commits into
mainfrom
v4

Conversation

@mgravell

@mgravell mgravell commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Tracking PR, not for immediate merge. v4 is the long-lived 4.0 branch; this PR exists to show its
diff against main and to collect review. It supersedes #3219, which was the spike this grew from.
When it lands it must be a real merge, not a squash: main is merged into v4 as it moves, so the
final merge stays small. Pending work and open decisions are in design/v4-ledger.md.

What 4.0 is

A new core, with the old one deleted. Message, ResultProcessor, PhysicalBridge,
PhysicalConnection and RedisDatabase are gone. Commands are rendered by an interpolated-string RESP
writer and sent through executors over the RESPite transport. Reconnect, backlog, resubscribe, redirects,
maintenance events, sentinel and logging (shipped event ids preserved) were ported and checked against
the old core before it was deleted.

IDatabase stays, served by the new core. Existing code keeps compiling and working; see
docs/LegacyApi.md. The old API is not deprecated.

A grouped, async-first command surface alongside it:

await db.Strings.SetAsync("k", "v");
using var members = await db.SortedSets.RangeByScoreAsync(key, 0, 10);   // a lease over the reply buffer

Commands are extension members on group types (Strings, Hashes, Keys, ...), so adding one - ours or a
third party's - is never a binary break to an interface. Replies can be leased rather than allocated, and
every call takes a CancellationToken.

Highlights

  • Less work on the IO loop. Commands are formatted on the caller's thread, straight into pooled
    buffers, and replies are parsed on the caller's continuation rather than by the reader. The core's IO
    loop now only frames bytes in and out, so one slow or large reply no longer holds up everyone else's.
  • A much more efficient API for ad-hoc commands and extension libraries (NRedisStack and similar).
    Instead of building object[] argument arrays for Execute/ExecuteAsync and picking through a
    RedisResult, a library writes ctx.SendAsync<T>($"FT.SEARCH {index} {query}") with a typed reply
    handler, getting the same zero-copy formatting, cluster slot routing, key prefixing and caching as the
    built-in commands, and can hang its own command groups off the same targets as extension members.
  • Client-side caching (ConfigurationOptions.ClientCache, RESP3, opt-in) driven by the library itself
    (CLIENT TRACKING), keyed by the rendered frame, with invalidation that cannot race the send; in a connection
    group, our own writes reach every member's cache. See docs/ClientSideCaching.md.
  • Batches and transactions on the new surface (SER014): db.BeginBatch() / db.BeginTransaction()
    return structs that are ordinary keyspace targets, with explicit ExecuteAsync, discard-on-dispose, and
    conditions via the existing Condition type.
  • Sync without the thread pool: Blocking() contexts send and wait on the calling thread, so a group
    method returns an already-completed ValueTask; the synchronous IDatabase methods are built on it, and
    so can a library's. DedicatedThreads connections connect and handshake synchronously, so on Linux their
    sockets never need a pool thread either.
  • For library authors: one generic accessor per group, Blocking() for sync forms, IDatabaseAsync.AsTask(...)
    for Task forms (async state and observed faults), and context.Multiplexer for connection-level settings
    such as AddLibraryNameSuffix. See docs/Extending.md.
  • Cluster: routing by CLUSTER SLOTS, batches split per slot, MOVED/ASK handled in the executor.

Compatibility

  • Package and assembly version 4.0; prereleases publish from this branch as 4.0.N-alpha.
  • Newer surfaces are gated behind [Experimental] IDs (SER011, SER012, SER014; see docs/exp/).
  • Additions to the IDatabase family are deliberate and expected in a major version; removals and
    signature changes to shipped members are not.

Status

The full suite runs on Ubuntu (net10.0) and Windows (net10.0, net481); CI is green at the branch head (3cf425d).
Latest prerelease: 4.0.127-alpha (2026-10-12). Intermittent failures are listed under Watch, with detail in the ledger.

Pending

Mirrors design/v4-ledger.md (last updated 2026-10-13), which has the detail; ticked here as items close.

Decisions

Work

  • Run ActiveActiveCacheScenarioTests against a real Active-Active deployment - written and compiled, never executed (needs the fault-injector environment)
  • Two-core leftovers: Subscription._subscribed/IsSubscribed/AnySubscribed; ~30 ConnectionsIfCreated?. null-tolerances; topology flowing both ways between RespTopology and ServerSelectionStrategy
  • Subscription re-aim waits for its in-flight send to fail (~2s on Windows)
  • An abandoned connect (timeout) keeps its socket in SYN-SENT until the OS gives up - ConnectAsync is called without the token (default mode only)
  • Retry: stale comments in RetryDatabase.ExecuteAsync and RespRetryExecutor
  • Prose still says "the new core" in comments and docs
  • Keep absorbing main by merging (ongoing; the final landing must be a real merge)
  • Stale design notes on the cache - corrected in design/client-side-cache.md (378a103)
  • Client-side cache: PFCOUNT is cacheable and retryable - its write is internal bookkeeping (d59d4e2)
  • Sync Send<T> over a pre-built frame defaults its handler, as the async twin does (97ec53a; from the NRedisStack port)

Watch (flakes, not yet reproducible)

  • GetServerByKeyMemoization (RESP3, Windows net481) - speculative fix d45eadf; not seen in the 60 CI runs since 2026-10-09
  • "Nothing inbound" timeouts in RespCacheChurnTests - 2026-10-09, and again 2026-10-10 (Ubuntu) and 2026-10-12 (Windows net10.0); take a dump mid-stall next time
  • BacklogTests.TotalOutstandingIncludesBacklogQueue and SentinelTests.AbortOnConnectFailFalseRecoversOnceSentinelsAppear - intermittent, pre-existing
  • net8.0 cluster tests timing out at 10s under the full suite
  • QueuedResultTests.RetryTransactionFireAndForgetSharesOneCompletedTask (once)
  • MaintenanceOptInServerTests.SequenceIdsAdvanceAndCanBeRepeated fails when its class runs alone
  • CancellationTests.WithCancellation_CancelledToken_ThrowsOperationCanceledException - a race in the test (Task.WaitAsync returns an already-completed task as-is); should cancel through the API's own token
  • Known flakes: TouchIdleTime, RespAggregateTiming, ADeferredWalkNeedsNoStorageAtAll, and seven single CI occurrences on 2026-10-10..12 (listed in the ledger)

Backlog (after the alpha)

  • Client-side cache across a geo/active-active failover - our own writes stamp every member's cache, so a failback cannot serve a value we replaced; an inactive member is kept honest by the heartbeat consistency checks groups force on (746a065; live run under Work)
  • Alternative client-side-cache invalidation sources - keyspace notifications (__keyspace@<db>__:*; needs notify-keyspace-events, no RESP3 requirement) and explicit pub/sub (an application-defined channel the writer publishes to), for deployments without usable CLIENT TRACKING (proxies, older servers, some managed offerings)
  • ... and with it the rest of the cache opt-in/out story: per multiplexer and per command, and what happens when the chosen source is unavailable (refuse loudly, as RESP2 + cache does today, or degrade to no caching)
  • Client-side cache: public counters / metrics (hits, misses, stored, refused, invalidations, stale-served) - internal today
  • Client-side cache performance, still to explore: the gap to Lettuce's 79% hit rate (model frequency-vs-victim admission and second-chance eviction first), typed parsing on the cached miss, per-send route resolution in CanWritePreamble, RespKeyTable node lifetime, the parked builder-inlining patch
  • Replace the method-replaying retry/prefix/group decorators with executor decorators (batches through a retrying executor, sync, non-context inners to settle first)
  • Operation pool size at batch depth (128 slots vs thousands in flight) - the lever the batch profile points at; then batch/transaction buffer packing
  • Unobserved faults on the new ValueTask surface (non-pooled async ValueTask only; low priority)
  • Performance: remaining ideas (each single-digit percent; parked)
  • Trusted-callback completion: inline completion measured and ruled out as a default; the one variant left is batching pool wakes per parse pass
  • Non-nullable AppendFormatted(long)/(double) overloads (NRedisStack; needs an overload-resolution sweep)
  • .NET 11 TFM: try runtime async; prototype caller-driven TLS (TlsBufferSession); run the suite and RespFest on 11 early

A send to a node that has stopped accepting writes never completes, so the endpoint it
recorded was never cleared and HasSendInFlight answered yes for ever - after which every
EnsureSubscribedToServer returned "nothing to do" and the subscription could not be
re-placed at all. Bounded by the configured timeout.

Bounded by TIME rather than by asking whether the connection is live, which was tried and
is wrong: a subscription socket that is still dialling is not live either, so liveness
cannot tell "not up yet" from "never coming", and treating the first as abandoned reopens
the duplication window the guard exists to close.

Found while diagnosing RetirementUnderMaintenanceTests, which it does NOT fix: that one is
EndpointForSlot having no reachability filter where the shipped selector passes
allowDisconnected: false, so this core keeps aiming a slot at an owner it cannot reach.
Written up in design notes 9h as the next step, along with the causes ruled out.
EndpointForSlot asked the topology who owns a slot and picked by role, with nothing asking
whether the chosen node could be reached - where the shipped selector asks it in its
signature, as allowDisconnected: false. So a slot whose owner had gone away kept being
chosen.

"Known down", not "not connected": this core dials lazily, so an endpoint nobody has
needed yet is unconnected and perfectly usable, and barring it would route every first
command away from the node that owns the slot. An endpoint counts as down only once it has
been dialled AND has a connect fault to show for it - which is the distinction
ServerEndPoint.PublishSelectable already relies on from the other side when it excludes
DidNotRespond.

Either socket counts. Asking only about the ordinary executor missed the case that matters:
a node this core has only ever subscribed on has no ordinary executor, which is exactly the
shape of a sharded channel's node going away.

And Settle now records NOTHING when a redirect points at a node known to be down. The
re-resolution reads the slot map, and a -MOVED updates that map whether or not the resend it
caused ever landed, so a redirect to a departed node was recorded as the subscription's home
- the one answer that is certainly wrong, since it is neither where we aimed nor anywhere
reachable. Unplaced is the truth and is what lets the next ensure retry.
RetirementUnderMaintenanceTests guards against vacuity by requiring the refusing node to be
accumulating our own probe traffic. Under the engine flag that state does not arise: this
core will not aim anything at a node it has dialled and found unreachable, so the node is
simply idle rather than busy-with-probes-but-idle-of-caller-work.

Verified before changing it by relaxing only that precondition - the whole rest of the test
passes, so retirement itself was never in question. The flagged path therefore asserts that
nothing is queued there at all, which is a property worth pinning, rather than skipping;
everything after that point still has to hold either way.

Design notes 9i records the three parts of the routing fix and why the first two were each
insufficient alone.
…oken

Three consecutive full runs of the same build: shipped fully green, flagged five to seven
failures with only ReconnectRetryPolicyUnitTests in common. The rest rotates almost
entirely, and most of it is timing - intervals, timeouts, connect failures, a profiled
redirect.

Naming it as its own problem because it is one: the stable count is two and the observed
count is five to seven, so quoting either alone would misrepresent it. The likely driver is
three sockets per node under RESP2 while both cores exist, which makes it the interactive
bridge's removal to fix rather than any individual test.
The wedge in 9b-xiii was seen a third time, and this time it produced no failure list at
all: a full flagged run exceeded its thirty-minute timeout and was killed, where runs of
the same build either side finished in about three minutes. Worth recording because CI
would report that as a timeout rather than as this.

Also rules out the obvious suspicion about 9j's rotation: these runs follow hours of
cluster tests that migrate slots without migrating them back, so the shared docker cluster
was checked directly - every node reports cluster_state:ok with all 16384 slots assigned
and ok. The topology is intact, so the rotation belongs to the client.
…what is missing

An earlier note recorded the gate as measured closed. Re-measuring - gating the interactive
handshake's AutoConfigureAsync on !NewCoreEngine and running the full flagged suite - gives
26 failures, not baseline.

The failures cluster by belief, which is the useful part: the new core's handshake does not
publish the product variant and version, the tie-breaker, CLUSTER NODES as distinct from
CLUSTER SLOTS, or the SET replica probe used when HELLO cannot report the role. Each is
named in design notes 9k with the tests that catch it, and with the order to close them in.

The handshake already asks INFO server; it just discards everything except redis_version,
so the product variant is the cheapest of the four and the largest cluster of failures.
DiscoverProductAsync reads INFO server and derives the product and its version, reusing
AutoConfigureInfoField and its generated TryParse so the field names keep one spelling. It
is not a cosmetic label: ServerEndPoint decides whether a cluster supports multiple
databases from it, so a client that never learns the product applies the wrong rule.

From INFO rather than HELLO, matching the shipped core: HELLO's server field would be free
but names the product without its product version, and the version is half the belief. The
variant is decided across the whole reply, because redis_version and valkey_version can both
appear in either order.

Placed in DiscoverServerConfigAsync, already the interactive-only home for beliefs nothing
else has described, so the handshake transcript is untouched - several tests assert it
exactly. Gated on an empty recorded product version, the same "has anyone said?" test the
database count uses.

With the shipped auto-configure gated off this closes ProductVariantUnitTests (7 of 26).
Design notes 9l records the rest, including why closing the other three gaps individually
will not close the gate: the next failure is not a missing belief but a missing connection.
…erdict

Wired the whole coupled step as an experiment - shipped core stops dialling, this core
dials at connect via ConnectMode.Discover, IsConnected and IsSelectable answer from this
core, shipped auto-configure gated off - and measured it: a 311-test family went from ~20s
to 6m30s with 36 failures, spaced ~15s apart.

The 15s is ConnectImplAsync deciding the multiplexer never connected:
ReconfigureAsync(first: true) returns false and connect throws UnableToConnect after its
retries. Every failure in the list is that one exception, so nothing else about the coupled
move is measurable yet.

One hypothesis eliminated and deliberately not landed: completing OnConnectedAsync's pending
connection monitors from this core's establish, which is the obvious candidate and changed
the measurement not at all - same failures, same duration, same spacing. Dropped rather than
committed, because landing a plausible-but-unverified change is exactly how the "gate
measured closed" error in 9k happened.

Experiment patch kept in the scratchpad; design notes 9m records it.
ReconfigureAsync proves an endpoint is reachable by sending a tracer down the "already
connected, show me" path of OnConnectedAsync, and WriteDirectAsync puts that on the shipped
bridge - so under the engine flag it proves the wrong connection, and against the step that
stops that bridge dialling it is written to a connection that will never carry it and never
completes. ConnectFailTimeoutTests.NoticesConnectFail is exactly the test that catches it.

Declines unless the other core actually has the endpoint connected, so it cannot report
"unreachable" about a server the bridge is talking to perfectly well.

Measured clean in both default configurations: shipped fully green, flagged within its usual
rotation band.

Design notes 9n records this alongside the two hypotheses that looked just as good and fixed
nothing - completing the pending connection monitors from this core, and dialling this core
before the connect verdict - both dropped rather than committed. The ~15s per connect that
the coupled move still costs remains unexplained, and the next step is to read the connect
log rather than reason about it.
…ath is 82% of the rest

"How much still travels as a Message" had been answered by counting source sites, which
flatters the position badly - RedisDatabase.cs holds 395 of them and under both flags not one
runs. Measured instead, with a counter on the Message constructor over the same 2,282 tests:
79,114 messages shipped against 9,772 under both flags, and zero data commands in the
flagged run. No SET, HSET, LPUSH or ZADD at all.

Of the 9,772: the connect path is 8,050 (82%), the tie-breaker GET 862, SENTINEL 432,
SUBSCRIBE/PSUBSCRIBE 403 (built to route, never sent), SCAN 25.

So there is one structural problem left between here and a single core - the connect path,
which is where 9m/9n is stuck - rather than a long tail. Counter reverted rather than kept;
design notes 9o says how to re-run it.
Reading the connect log, as 9n said to, named it immediately: the pending connection monitor
is handed out by OnConnectedAsync and never completed, so every endpoint runs out three
ConnectTimeouts.

9n's first hypothesis had the mechanism right. It failed because it and the second hypothesis
were two halves of one change and I tested them separately - the hook had no connection to
fire from, the ordering had nobody to complete the waiter. Together they still failed, and
that exposed the real finding: muxerEndpoints=0. The multiplexer has no endpoints until
ReconfigureAsync resolves them, so any dial before it iterates an empty list and does
nothing. The dial belongs inside that method's per-endpoint loop, beside ActivateServer.

With that, connect goes from 15,081ms to 72ms, the log shows the monitor completed by this
core, and the shipped interactive bridge never connects at all. The 311-test family goes from
36 failures in 6m30s to 24 in 50s, and not one remaining failure is a connect failure - they
are the belief gaps already itemised in 9k, with the tie-breaker the largest.

Not landed: the enabling dial is not inert by default - it would add a socket per endpoint
while the shipped bridges still dial - so it lands with the switch that stops them. Patch
kept in the scratchpad; design notes 9p.
The handshake took the server type from HELLO's mode field and from CLUSTER INFO, which
leaves two cases unanswered: a server with no HELLO - exactly the case that needs another
source - and a product that spells the field server_mode rather than redis_mode.
ValkeyUnitTests is built on both at once and was read as Standalone.

Read in the same INFO server pass that already derives the product, using
ServerTypeMetadata.TryParse and accepting both spellings, which is the shipped processor's
rule rather than a guess. Guarded the way Publish guards it: a sentinel or a proxy is not
something an INFO section gets to overrule.

Against the coupled move this takes the 311-test family from 24 failures to 18, clearing all
four ClusterTopologyUnitTests and both ValkeyUnitTests - so those were never the CLUSTER NODES
gap they had been attributed to in 9k/9p, they were this. Measured clean in both default
configurations.
…shake

AUTH was awaited with no catch, so a server that refused it failed the whole handshake -
where the shipped handshake writes AUTH fire-and-forget, cannot read the reply at all, and
therefore continues while recording the suspicion via SetAuthSuspect. Commands then fail
individually with the server's own words, which is a far better diagnostic than a connection
that never exists.

The case that made it matter is not a wrong password but ONE configuration spanning servers
with different requirements: a config carrying the secure server's password also reaches the
plain one, which answers "Client sent AUTH, but no password is set". Awaiting that reply took
a perfectly good endpoint out of the deployment.

Also reads the tie-breaker, which is the deciding vote in the primary election - without it
every endpoint logs "had no tiebreaker set" and then "No primaries detected". Re-read on
every handshake rather than cached, because a tie-breaker is mutable state and changing it is
how an operator moves the election. Database 0 named explicitly: a tie-breaker is a key, the
asking context may name no database, and GET is refused before it reaches a socket - which is
what RespNewCoreDiscoveryTests caught, in both configurations.

Against the coupled move these take the 311-test family from 18 failures in 50s to 6 in 11s,
clearing all ten MultiPrimaryTests.TestMultiWithTiebreak and both ConfigTests.MutableOptions -
so MutableOptions' "could not connect" was this, not a separate structural problem.
A role is not a preference, it is a routing fact - the client refuses writes to a replica on
the strength of it - and unlearned, every server looks like a primary.
MultiPrimaryTests.CannotFlushReplica connects to the replica, looks for the server where
IsReplica is true, and finds none.

From INFO replication, which is the only source always available. ROLE is better where it
applies, since a primary lists its replicas and one reply describes both sides, but it is
asked only when the topology says roles could change a decision - which with a single
configured endpoint they cannot, and that is exactly this case. HELLO carries a role only
under RESP3. master_host and master_port come from the same section, so PrimaryEndPoint is
read with it.

Re-read per handshake like the tie-breaker rather than cached like the product: a failover
changes this.

Takes the coupled family from 6 failures to 5; design notes 9q records the whole 24 → 5 run
and corrects two wrong attributions - CLUSTER NODES turns out not to be needed by anything
measured, so the 9k itemisation over-counted by a row.
Four pieces, each needed by the coupled move and each correct on its own terms.

KEEP-ALIVE. The heartbeat reached this core's executors but only ever expired backlog and
pending - it sent nothing. An idle connection is a connection being timed out: servers close
one after their own 'timeout' (the very setting this core reads during discovery) and the
network between does it sooner. Two schedules, as the shipped bridge has: every heartbeat
when consistency checks are on, since noticing a dropped stream promptly is their whole
purpose, and otherwise once the connection has been quiet for WriteEverySeconds.
Fire-and-forget, because a heartbeat that waits for a server that has stopped answering is a
heartbeat that has stopped beating for everything else.

OPERATION COUNT. ServerEndPoint.OperationCount summed bridges, so it read zero for a server
this core had been talking to all along - which is also how a test asks whether the heartbeat
is doing anything at all.

CONNECTION ID. The handshake now asks CLIENT ID, and GetConnectionId answers from whichever
core holds the connection. It is the only handle on a connection from outside - CLIENT LIST
names it, CLIENT KILL takes it - and it was null for the connection actually carrying the
commands. The subscription leg falls back to the ordinary connection when it IS the ordinary
connection, which under RESP3 it is; answering null there would describe a connection that
does not exist rather than the one carrying the deliveries.

Together with the earlier handshake work this takes the coupled 311-test family from 36
failures in 6m30s to 0 in 4s. Measured clean in both default configurations.
The 311-test family is fully green under the coupled move - 36 failures in 6m30s to 0 in 4s -
but it was not representative, and the full suite says 126. Recording that plainly, because
optimising against a slice made the position look much better than it is.

Grouped, it is one piece rather than a scatter: maintenance notifications are 70 of the 126
across six test classes, and they are a coherent feature - the opt-in is sent only by the
shipped handshake, and the pushes, relaxation windows, retention and replay all hang off a
connection that has opted in. This core neither opts in nor would recognise the pushes.

That reframes the census in 9o: the connect path was 82% of remaining Message traffic by
volume, but by test weight maintenance is the largest thing still on the shipped bridge.

One run of this build was killed at the 25-minute timeout where neighbours took 3.5 minutes,
so 126 is from a run that completed rather than a stable average.
The opt-in is the feature's gate: the windows, retention, replay and migration handling all
hang off a connection that has asked, and one that has not asked is never told. With this
core carrying the commands and only the shipped bridge opting in, the client was being warned
on a connection it no longer used.

Sends CLIENT MAINT_NOTIFICATIONS ON [moving-endpoint-type <t>] and reads the reply, which is
why it is asked from here rather than written blind - "agreed" and "declined, because X" are
different facts. Both decisions are borrowed rather than restated: the shipped
ShouldRequestMaintenanceNotifications and MaintenanceMovingEndpointTypeLiteral gained
overloads this core can call, and the shipped callers delegate to them.

moving-endpoint-type needed real plumbing rather than an approximation. For Auto the type is
derived from the address actually reached and whether the connection is encrypted - not the
endpoint dialled, which is usually a name - and only whoever built the transport knows
either, with encryption sometimes the tunnel's decision. RespTransportFactory.ConnectAsync
now reports both. Guessing internal where it is external sends a client to an address it
cannot reach when a shard moves.

Fixes ~11 maintenance failures; 58 remain and all need the push path. Design notes 9s records
its shape: lift PhysicalConnection.OnMaintenanceNotification's ~270 lines of deliberately
tolerant parsing out so both dispatchers call it, rather than writing a second copy of rules
that exist for go-redis compatibility.
9s said the 58 remaining failures wanted the push path and that the move was to lift the
parse rather than copy it. It was a smaller refactor than it looked: eight of the eleven
members were already static, and the three that touched 'this' wanted exactly four facts -
the server, whether the connection is established, the address it reached, and a trace sink.

So OnMaintenanceNotification becomes ReadMaintenanceNotification taking those, the instance
method is a four-line wrapper, and RespPushDispatch calls the same routine. The ~270 lines of
deliberately tolerant parsing are untouched: no second spelling of the optional sequence id,
the movable time, SMIGRATED's nested triplets or the explicit nulls.

It returns void rather than OutOfBandResult, which is private to PhysicalConnection and a
different enum from the new core's: a maintenance notification is never a reply, so the frame
is consumed either way and there is nothing to signal.

OnMovingAnnounced took a PhysicalConnection and used it for one thing - the address the
announcing connection reached - so it now takes the address. That is the third time this
session that 'the address we actually reached' turned out to be the real parameter hiding
behind a connection.

Maintenance failures 58 to 17. Shipped suite fully green.
ReconcileMaintenanceNotifications decides whether an unavailable-but-Enabled feature should
fail the connection. The rule and its wording are now an internal overload returning the
failure message; the shipped caller turns it into RecordConnectionFailed and this core throws
out of its handshake, which is the same thing in its terms - a handshake that throws fails the
connect, and "Enabled means required: no notifications, no connection" is exactly that.

And a correction. This core had been asking for the opt-in only when RESP3 was actually
negotiated, rather than on intent as the shipped core does - strictly fewer round trips, same
end state, apparently better. It is not better:
MaintenanceOptInClientTests.AutoIsOffWhenTheServerDowngradesToResp2 requires the server to
have SEEN the opt-in and the client to disbelieve the acceptance anyway. That sequence is
observable, deliberate and covered, so skipping it is an unflagged behaviour change dressed
up as an optimisation. Now asks on intent, with the reasoning written where the temptation is.

Also: a group member never asks but must warn that it is not asking, because a caller who
wrote maintNotifications=Enabled asked for a guarantee - silently suppressing it is the gap
the warning exists to close. And the suppressed path still reconciles, since Enabled over
RESP2 never gets as far as asking and failing it is the point.

Maintenance 70 to 8; the flagged suite is at 3, shipped fully green.
A MOVING handoff exists to stop using a connection before the server closes it, and under the
flag that connection is this core's - so recycling only the shipped bridges replaced the
sockets nobody was using and left the one carrying commands to be cut mid-flight.

Both halves of DrainThenRecycleAsync now span cores. HasCallerWork sums this core too, and
deliberately coarsely: the question is whether recycling now would lose somebody's work, and
over-counting costs a short wait where under-counting costs the command. The recycle itself is
DropConnection(reconnectImmediately: true), which already existed for the failure simulation.

And it has to be reported, which is the half the test judges. The first attempt recycled
correctly and still failed, the assertion saying so in its own words: "the recycle should be
reported as a MaintenanceHandoff rather than silently". The replacement raises
ConnectionRestored, so a silent drop leaves a consumer with an unpaired restore, and the
failure type is what says it was deliberate rather than a fault.

Maintenance 8 to 6; flagged suite at 4, shipped fully green. What remains is retention/replay
(where the two cores disagree about what "established" means for a catch-up copy), the
cross-node event dedup, and two relaxation-side tests - design notes 9v.
The three retention tests were not about what "established" means - that was the wrong guess
in 9v. They were an ordering bug, and the same one the configuration channel hit at the other
end of this method: DiscoverServerConfigAsync sends the maintenance opt-in, which makes the
server replay whatever it retained for the shard immediately, and OnPush was not wired until
afterwards. The replay arrived at a connection with no dispatcher and was dropped as
unrecognised, so the "(catch-up)" line the tests look for never appeared.

Moving the OnPush and DeliversArrays assignment above the discovery block fixes all three.
The rule was already written a few lines further on for the configuration channel subscribe;
the opt-in is the other thing that asks a server to start talking, and both now sit below the
wiring.

Maintenance 6 to 3; flagged suite 4, shipped fully green.
With everything from the connect path through maintenance landed, the coupled move is at 59
failures, down from 126. Maintenance was 56% of the first number and is 3 of this one.

Recording the shape as well as the count, because it has changed: there is no dominant
cluster left - 25 classes, the largest 8. Every step from the connect verdict through the
push ordering was one thing blocking many tests and paid for itself several times over; a
25-class tail with no cluster above 8 will not behave that way, and the honest expectation is
that it costs roughly its length.

Two entries are known not to be work: RetirementUnderMaintenanceTests is the premise question
already analysed in 9h/9i, and SentinelTests keeps its own bridge by design.

Design notes 9x, including which of the remaining groups look like one cause rather than
several.
All seven ExceptionFactoryTests failures under the coupled move were one cause: the socket
and pipe byte counts were missing from the message. They were missing correctly. These tests
hand in a DETACHED ServerEndPoint with no bridge, so GetBridgeStatus falls through to
whichever core has an executor for that endpoint - and this core reports -1 for
BytesAvailableOnSocket, BytesInReadPipe and BytesInWritePipe on purpose: it polls no socket
and has no pipe, so there is no number to give. ExceptionFactory omits a negative rather than
printing it, which is the honest outcome; filling them with zeroes would be inventing data in
the one message people read when diagnosing a timeout. That core reports the equivalent in
its own words, as the outbound/inbound figures on its timeouts.

So the counters that exist on every core are asserted unconditionally, and the three that
belong to a socket-and-pipe implementation are asserted only when that implementation
supplied the status - asking the same question GetBridgeStatus asks, which is whether the
other core has an executor for the endpoint rather than whether it is connected: an endpoint
it tried and failed to reach still has one, and still supplies the status.

Green in both configurations.
Three parts of one gap: this core treated HELLO purely as the RESP3 negotiation, where the
shipped handshake treats it as discovery that happens to negotiate.

HELLO ON RESP2. The reply carries the server's version, mode and role, so asking is cheaper
than the INFO sections that are the alternative source for all three - which is why the
shipped handshake sends a bare HELLO even with no intention of speaking RESP3. The protover
asked for is the one actually wanted: asking for 3 while configured for RESP2 would be asking
to be upgraded against the caller's wishes, so RESP2 asks for 2, which cannot change the
protocol. HelloHandshakeTests asserts HELLO is issued for both, with the matching protover.

CREDENTIALS IN HELLO when AUTH is unavailable. A command map that disables AUTH is not a map
without credentials - a proxy can require them and refuse the command - and HELLO is then the
only thing that can authenticate the connection. This core sent AUTH unconditionally, so such
a deployment got a RedisCommandException out of the handshake and no connection at all. Now
it makes the same split as the shipped handshake, and never sends both.

AND WHETHER TO SEND IT AT ALL is ConfigurationOptions.TryHello's decision, not a command-map
check: a deployment also opts out by declaring an assumed server version older than 6.0
(defaultVersion=5.0), and TryHello is the one place that weighs both. Passed in rather than
re-derived.

HelloHandshakeTests 5 failures to 1 under the coupled move; the remainder is the SET replica
probe, which is its own gap. Shipped suite green.
…when nothing else can

A node that prefers hostnames and announces none reports "?", which parses perfectly well
into a DnsEndPoint nobody can dial. The redirect path already refused these - IsUnroutableTarget
exists for it - but ClusterSlotsHandler.TryReadHost only checked for an empty host, so the
placeholder went into the slot map and commands were routed at a server called ? and expired in
the backlog. UnroutableRedirectUnitTests reads that as a connection exception where a server
exception was expected: the client was dialling instead of letting the server's error stand.
Fixed by sharing the predicate rather than copying it - a server can name a placeholder anywhere
it names an endpoint, and the forms are identical in all of them.

Also adds the SET replica probe, the last of the four gaps itemised in 9k. With HELLO, INFO and
CONFIG all unavailable nothing will answer "are you a replica?", so the client asks by
attempting a write and reading the refusal: NX so it cannot overwrite, PX 1 so it cannot
persist, the client's own id as the key, and never in a cluster where the answer would be MOVED.

The probe needed a second fact to be right. Firing it whenever INFO was unavailable broke
NoReplicaProbeWhenHelloTellsUsTheRole, because HELLO carries the role and a server that
answered one has settled it. So HELLO's role field is now read - free, and it publishes
IsReplica directly - and the probe is skipped when the role is known, which matters because the
probe needs a key and an ACL can forbid that (#2968).

UnroutableRedirectUnitTests 6 to 0, HelloHandshakeTests 5 to 0. Shipped suite green.
CLIENT LIST was ported to the context some time ago; CLIENT KILL next to it
still built a Message, so under the engine flag it went down a pipeline with
nothing on the other end. ClientKillTests reads that as a cancelled task: the
command never reached a server, which is not something to report as a kill
count.

It lands in RespDiagnostics under protest, as ClientListArray's own remarks
predicted - killing a client is administration, not diagnosis. An admin group
is the eventual home, and inventing one for a single verb would decide the
shape of a public surface as a side effect of a port.

Both public spellings - the filter object and the four loose arguments - now
render through ClientKillFilter, which is where replica-vs-slave is decided for
this server; a second encoding would be free to disagree about that. The
positional CLIENT KILL <addr> form keeps a method of its own, because its wire
shape and its reply (+OK, not a count) genuinely differ, and it is the only
form the oldest servers accept.
…rser

Six things, all one thread: the facts this core discovers about a deployment
were staying inside it, so surfaces that answer from ServerEndPoint went stale
or empty.

Roles were a one-way push. RespTopology learns them from three places - a ROLE
reply, the owners in a CLUSTER SLOTS reply, and the primary a -MOVED names -
and recorded them nowhere else. But ServerEndPoint.IsReplica is what refuses a
write to a replica, read by both RespEndpointExecutor and the shipped selector,
so after a failover this core knew the truth and the flag stayed stale. The
consequence is worse than staleness: the refusal is client-side, before a byte
goes out, so no -MOVED comes back and nothing corrects it. RoleLearned is the
other half of the push ServerEndPoint.IsReplica already made inward; the bounce
back terminates on the first hop, because OnRole's existing "changed?" check
already matches by then.

RefreshTopologyAsync gives this core the topology refresh it never had.
Discovery happens at handshake, once per deployment, so every later change
arrived via the shipped AutoConfigureAsync sweep - and a core with live
connections had no way at all to learn the deployment had moved, because
nothing reconnects. It asks CLUSTER SLOTS once for the deployment and CLUSTER
NODES per connection, mirroring that sweep.

CLUSTER NODES is now asked at handshake too, per connection, which is the
deliberate opposite of the CLUSTER SLOTS rule immediately above it: SLOTS is a
fact about the deployment and one answer serves every connection, while NODES is
answered from the point of view of the node asked and `myself` is the part that
matters. ServerEndPoint.ClusterConfiguration is that per-server view, and
IServer.ClusterConfiguration, InventKey (via GetServableSlot) and the tracer key
are all nothing but a read of it. One SetClusterConfiguration call also drives
UpdateClusterRange, ApplyClusterRoles and UpdateNodeRelations.

Because of that last point the SLOTS view has to be held per core and handed to
every server, not just whichever node answered: SetClusterConfiguration reads
ServerEndPoint.ClusterTopology to decide which reply drives the shipped slot
map and falls back to the staler NODES view when a server has none, so a second
connection publishing its own NODES would clobber a migration that SLOTS alone
reported. ClusterTopologyUnitTests.SlotMapIsDrivenByTheSlotsView is built to
catch exactly that.

The CLUSTER SLOTS parser is now shared rather than duplicated. This core's
handler was ~90 lines producing a reduced shape that could route and nothing
else: it read past the node ids and discarded them, so ClusterTopology could
not be populated here and identity-merging - what stops one node becoming two
ServerEndPoints when a reply names it differently, the TLS/SNI case - had
nothing to work from. The shipped parser already reads all of it and all but one
line is pure RespReader work, so ClusterSlotsResult.Parse is exposed and the
handler is four lines over it. That also subsumes the placeholder check added
separately here: the shipped ResolveEndPoint had always refused null/""/"?" and
an out-of-range port, which is exactly RespRedirect.IsUnroutableTarget's
coverage. Two implementations of one rule was the real defect.

Finally, GetEndpointsFromClusterNodes lets this core do the reading when this
core owns the connection - refreshing first, then taking the published views,
instead of sending its own CLUSTER SLOTS and CLUSTER NODES. That matters beyond
the round trips: a shipped Message write calls GetBridge with `create` defaulted
to true, so those two commands were the only thing still dialling the shipped
interactive bridge, and every endpoint carried two sockets with one doing the
work (ClusterTests.ConnectUsesSingleSocket reads that off the counters).

Ownership is tested rather than assumed, and that is load-bearing: under the
flag alone this core connects lazily, so a deployment nobody has sent a command
to has no connection here and the shipped bridge is the only thing that can
answer. The published view is non-null regardless, because shipped autoconfigure
fills it, so without the IsInteractiveConnected check the cached path reads a
cache nothing is refreshing and sends nothing - which left a slot-migration
notification provoking a pass that re-read nothing at all, measured by
MaintenanceTopologyRefreshTests and PeriodicTopologyRefreshTests counting
CLUSTER commands at the server.

Connection state now answers from whichever core holds the connection, as
IsConnected and OperationCount already did.

Shipped: green. Flagged: 8, in its usual rotation band. ClusterTests with the
coupled patch: 18 -> 0, and 19m51s -> 17s.
Three things the shipped bridge does from its heartbeat that this core never
learned, all of which matter for a client that outlives a deployment change.

Nothing re-dialled a down endpoint. OnHeartbeat returned early when there was
no live connection, so this core only ever dialled on demand: a connection that
went away stayed away until a caller happened to need it again, and an endpoint
whose FIRST dial failed was never tried again at all.
ConnectFailureRefreshTests reads that as "expected the endpoint to be retried
repeatedly, but saw 0 attempts".

RetryConnectIfDue fixes it, and the gate is the load-bearing part: retry only
where a dial has already been attempted or one has already succeeded. Either
means the endpoint is wanted; an executor nobody has used has neither and is
left alone, so lazy connection is preserved exactly - RespConnectionStateTests
is the check on that.

Repeated connect failures provoked no topology read. The mechanism already
exists and is already well-reasoned - ServerEndPoint.OnRepeatedConnectFailure,
above a threshold, rate-limited to ConfigCheckSeconds, with the "37 hours across
a node replacement" story in its remarks - and this core simply never called it.
It now reports the count and leaves the restraint where it belongs.

A dying connection raised no ConnectionFailed, and this one has consequences
past the test suite: it is a documented public event and the thing callers wire
up to notice a deployment moving underneath them. This core raised it for a
tripped circuit breaker and a maintenance handoff, and for nothing else, so an
ordinary dead socket was silent. Now raised from RecordFault, where the fault is
already computed - with the same distinction shipped makes, since a retirement,
a maintenance recycle or a dispose is not a failure. RecordFault therefore takes
wasRequested exactly as PhysicalBridge.OnConnectionFailed does, and the recycle
path passes it: that path already announces its own MaintenanceHandoff, so a
socket failure beside it was both redundant and misleading, which
MaintenanceNotificationTests asserts directly.

ServerEndPoint.LastException now spans both cores. It is what ExceptionFactory
collects to explain WHY no connection was available, so reading only the shipped
bridges meant "unable to resolve physical connection" arrived with no inner
exception and nothing to act on. Same treatment as IsConnected, OperationCount
and the connection state.

RespProtocolTests.TryConnect asked the shipped bridge for its connection id,
which under the engine flag does not exist; it asks the multiplexer, which has
answered across both cores since GetConnectionId was taught to.

Also here, as a comment rather than a change: failing the handshake outright on
WRONGPASS/NOAUTH was tried and withdrawn. It is the shape SecureTests asks for
and fixed that case, but ConfigTests.MutableOptions changes the password at
runtime and needs the connection to survive the window in which it is wrong, so
it traded one test for two. Shipped gets both by continuing and letting the
first command fail with the auth suspicion attached; that is where this core has
to arrive too.

Closes ConnectFailureRefreshTests x2, MaintenanceRelaxationTests,
ConnectionFailureErrorsTests and MaintenanceNotificationTests under the coupled
patch, which goes 21 -> 16. Shipped green; flagged at 6, its usual band.
…ct for dedicated); the cache is opt-in, not on by default
… cannot serve a replaced value

A connection group resolves the active member's cache per command, so a write made while B was active
stamped only B's cache. A's server hears of it only by replication, and A's tracking connection is told
only then - so failing back could answer from A's entry with the value the caller had just replaced, for up
to the replication lag: read-your-own-writes broken by a switch.

Each member's cache now holds its peers (RespClientCache.SetPeers, relinked by the group on construction,
add and remove), and a local write stamps them all. A then misses and asks its server, which may still be
behind - that is replication, and an uncached read sees the same; the cache is never staler than the member
it answers for. Server invalidations and flushes stay per member. Flushing on every switch was rejected:
with equal weights a group selects by latency, so the active member can change on any health pass.

The ledger's second worry - a silent inactive member - needs no code: group members force
HeartbeatConsistencyChecks, so every endpoint is pinged each heartbeat, and a half-open tracking connection
trips the dead-socket rule and flushes that member's cache, exactly as for an active one.

Tests: GroupCacheTests (in-process servers that never replicate; both write paths, the IDatabase forwarding
and the context; both fail without the fix) and the peer relinking on Remove. ActiveActiveCacheScenarioTests
in the fault-injector tier, against real replication - compiled, not yet run.
…ts async twin does

Found porting NRedisStack, whose command factories hand back frames: SendAsync<T>(ref frame) took an optional
handler (the inbuilt one for T when omitted), while Send<T>(ref frame) demanded a non-null handler and a token.
The synchronous overload now takes both as optional, matching the interpolated Send<T> - flags stay required,
as they are there. Unshipped API, so no compatibility cost.
…gRPC's call token - in Basics.md and AsyncTimeouts.md
…ce 2026-10-09); the PR-body-only backlog items (cache metrics, AppendFormatted, .NET 11) recorded here
mgravell and others added 9 commits October 15, 2026 11:20
…d drop the Logging.Abstractions reference where it is in the shared framework (NU1510) (#3276)
…lt now resolves to it (Broadcast when Prefixes are given, since PREFIX needs BCAST)
…y flag, the entry queue spares flagged entries once, and flagged entries are copied forward out of an evicted slab

Measured in-test against exact LRU and FIFO at the same capacity (Zipf s=0.99: LRU 66%, FIFO 62%, now 67%; hot set + scan: LRU 74.5%, FIFO 56%, now 74.5%), and in RespFest's cache league before/after (zipf hit rate 76% -> 79%; hit, churn and sad-path cells within spread). The hit path gains one conditional field write per entry per eviction cycle.
…te(RedisKey|span) and InvalidateAll(), acting as local writes (peers stamped, no grace period), plus the cache's counters
…OptOut, TrackingOptIn, TrackingByPrefix) (#3297)

* client cache: opt-in/opt-out by mode and by flag - CommandFlags.CacheOptIn/CacheOptOut (two bits, both-set reserved) replace NoClientCache; Tracking negotiates OPTOUT, new TrackingOptIn negotiates OPTIN, new TrackingByPrefix (OPTIN, Prefixes applied client-side, flags win) is what Prefixes now select by default; CLIENT CACHING YES|NO travels as a per-request prefix frame written as one unit with the request (RESPite IRespMessage.Prefix + discard sink); a transaction that reads is sent behind CACHING NO under OPTOUT

* tests: netfx expectations for the CacheOptIn/CacheOptOut formatting cases (the second zero-valued name, PreferMaster, is appended to every non-zero value on .NET Framework)
… ported onto the new core, and StyleCop 1.3 (#3298)

* Fix StringSet(Expiration, ValueCondition) doc link: set, not delex (#3272)

The overload (and StringSetAsync via inheritdoc) pointed at the DELEX
command page; it was copied from StringDelete. Also add the missing
<returns> to match the sibling overloads.

Fixes #3269

* Tolerate refused CLUSTER SLOTS/NODES during topology updates (#3275)

* Tolerate refused CLUSTER SLOTS/NODES during topology updates (#3274)

Proxied deployments (Azure Managed Redis / Redis Enterprise) can identify
as a cluster while refusing CLUSTER SLOTS ("ERR command is not allowed";
Enterprise exposes SLOTS only under the OSS cluster API). That refusal
surfaced as a logged *error* from GetEndpointsFromClusterNodes at connect
and on every reconfigure, including the periodic topology refresh.

A refusal is a posture, not a fault: catch RedisServerException per task -
SLOTS refused falls back to the NODES-only path (as the method's own
comment always intended), NODES refused means no topology view at all, so
return null; both are logged at Information (EventIds 128/129).

Tests: ClusterCommandsRefusedUnitTests drives the in-process test server
with SLOTS (and SLOTS+NODES) refused, asserting discovery still works and
no error is logged; CapturingLogger promoted from ConnectFailureRefreshTests
to a shared test helper.

* Observe both topology tasks, so an abandoned one never faults unobserved

SLOTS and NODES are issued as a pair; if one await throws anything other
than a server refusal, the other task is abandoned mid-flight - and both
ride the same connection, so the orphan is likely to fault too. Attach
ObserveErrors at creation so it can never surface as an
UnobservedTaskException in the host. (Pre-existing gap: the bare
sequential awaits had the same shape.)

* Report a transport connection's failure the way a stream connection's is reported (#3273)

* Lag-aware failback checks for geo-redundant failover (#3242)

* Investigate Redis Enterprise lag-aware availability checks

Confirms the premise: this is the client-side geographic failover feature
family, and Lettuce, Jedis and redis-py have all shipped it. It feeds exactly
the health checks our Availability/ namespace already has behind SER007.

The abstractions line up almost one-to-one with redis-py's - probe, policy
(all/any/majority), circuit breaker, per-member config - and two details make it
land cleanly: HealthCheckProbe has no internal abstract members, so it is
already subclassable from outside the assembly, and ConnectionGroupMember
already carries a per-member HealthCheck override, which is what a per-cluster
REST endpoint needs.

Where we are better placed: HealthCheckResult has Inconclusive, which Lettuce's
two-state HealthStatus lacks. Lettuce maps a failed REST call to UNHEALTHY, so a
management-plane outage is indistinguishable from the data plane being down and
can fail over a database that is serving fine. We can say "could not
determine" instead.

The awkward part is that src/ has no HTTP client and no JSON parser. JSON turns
out to be avoidable if the bdb uid is configured rather than discovered, since
the availability call is a status-code check.

Also records the defaults disagreeing across clients: 100ms everywhere -
server, docs, redis-py - but 5000ms in Lettuce.

* Record what a Tunnel API for the management plane should look like

mgravell offered to add one; the shape is what decides whether it helps.

A member returning HttpMessageHandler would drag System.Net.Http into
StackExchange.Redis's public API for the sake of one commercial deployment
target - which is the thing keeping this in a separate package was meant to
avoid, decided the expensive way. A stream-shaped member instead composes into
SocketsHttpHandler.ConnectCallback, which takes exactly a ValueTask<Stream>, so
the Enterprise side gets a complete HTTP hijack and core gains no HTTP types at
all. Tunnel already speaks that language via BeforeAuthenticateAsync and
ConnectTransportAsync, and adding a virtual is binary-safe for subclasses.

Two functional arguments for Tunnel over a separate knob, beyond testing: a
CONNECT proxy configured for Redis almost certainly applies to the Enterprise
REST API too, so co-locating makes that correct by construction; and the test
harness already assigns Tunnel, so one assignment would spoof both planes.

Caveats recorded: ConnectCallback is net5+, the existing members are
EndPoint/ConnectionType-shaped where HTTP wants a Uri, and widening Tunnel's
remit means HttpProxyTunnel and LoggingTunnel each need an opinion about whether
they apply to the management plane.

Also retires open question 5: RawConfig and ConfigurationOptions.Tunnel are both
already public, so a probe reaches the tunnel from the context it already gets -
no HealthCheckContext change needed.

* Real clusters and the fault injector, and a correction

I had written that inducing "reachable but stale" on demand is not something
anyone can do in CI, so the fakes were the only route to the scenario that
matters. That is wrong. Redis ships a fault injection service that the client
suites drive over HTTP - POST /action with a type and parameters, poll the
returned action_id - and Lettuce's ActiveActiveFailoverScenarioTest injects
network_latency against a bdb_id for a bounded duration, which is exactly the
mechanism for pushing replication lag past the tolerance and watching the
lag-aware check flip while the plain check stays green.

So the authoritative end-to-end test is real cluster plus fault injector, on a
road Lettuce has already built. The fakes are demoted rather than removed: they
still cover machines with no Enterprise cluster, they are far faster than an
injector action that budgets minutes plus a stabilisation wait, and they cover
what the injector does not obviously reach - specific error_code bodies, 401/403
credential expiry, slow-but-successful responses for probe timeouts.

Also records what the real clusters are uniquely good for: everything in section
1 is read off documentation, and a short session settles whether extend_check=lag
is actually supported, what failure bodies really contain, whether the Host
header is required, what the bdbs listing really returns - which decides whether
the JSON-avoidance argument holds - and what lag real geo links show, which is
the empirical way to settle 100ms versus 5000ms.

* We already have a fault-injector tier; it answers several of the open questions

It is not merged - tests/StackExchange.Redis.FaultInjector.Tests lives on
marc/maint-optin-server, open as #3191 (107 files, +13160) - which is presumably
why I did not find it when I grepped a branch taken fresh from main.

Two pieces of it bear directly on this. FaultInjectorClient wraps the same
POST /action + GET /action/{id} contract go-redis, redis-py and node-redis
converged on, and adds GetValidTriggersAsync, so we can ask a deployment which
lag-inducing effects it supports instead of guessing at an action name. And
ClusterRestClient is already a Redis Enterprise REST client on 9443 - Basic
auth, System.Text.Json, GET /v1/bdbs?fields=uid,name - so the availability call
is a method on an existing class, not a new project.

It also settles by observation what I had listed as open and sourced only from
documentation: the management certificate is self-signed per environment and
pinned to a CA from the config directory, so the probe needs CA configuration
and cannot rely on the ambient trust store; auth really is Basic; and the bdbs
listing returns the requested fields, so discovery is easy - which weakens the
avoid-JSON argument to "it keeps JSON out of src/", not "discovery is hard".

Sequencing consequence recorded: an end-to-end test is downstream of #3191.
And #3191's own summary is the best argument for validating before building -
same vendor, same class of API: the specifications are prose, the payloads were
never published, and nearly every starting assumption was wrong.

* Add the plan, blocked on #3191

Step 0 is to wait for #3191 to merge and build from there, rather than writing a
second Redis Enterprise REST client against the same cluster and reconciling the
two later - its ClusterRestClient already does Basic auth, CA pinning and
GET /v1/bdbs, and its FaultInjectorClient can be asked which lag-inducing
effects a deployment supports. The one thing that can start earlier is the
API validation, because it produces notes rather than code.

Step 1 is that validation, before any implementation: a checklist of things
currently taken from documentation, including the measurement that settles
100ms versus 5000ms, with captured bodies saved as fixtures so the stubbed tests
replay real payloads.

Step 2 deliberately does not decide where the code lives, but records that the
dependency argument is weaker than it looked - System.Net.Http needs no package
reference on any target we ship - so the question is judgement, not constraint.

Also records that this branch becomes the feature branch when step 0 clears, and
that the way to take the merge is to rebase onto main rather than merge main in,
since #3191 squash-merges.

* Step 0 is cleared; note that step 1 needs the machine with cluster access

#3191 merged as dc915bb, so this branch is rebased onto it - which is what step
0 asked for, and rebased rather than merged because #3191 squash-merged and a
merge would have moved the merge base without bringing its ancestry. The
fault-injector tier is present here now.

Also recorded where the work can happen. Step 1 is validation against a real
deployment plus the fault injector, and both live on the machine holding the
environment directory; this box has no ~/aws and no env_output.json anywhere, so
nothing in step 1 could be run or verified here. Worth writing down rather than
rediscovering, since the environment being absent looks identical to the
environment being offline. Steps 2 onward are ordinary code and are not tied to
a machine.

Noted too that the directory is the AWS multi-cluster template, so env_output.json
nests its outputs under .clusters.value[0] - a shape FaultInjectorEnvironment
already handles, so it needs no work, just no surprise.

* Pin the cluster REST certificate per host rather than chaining to the config CA

The 9443 certificate is self-signed by the cluster itself; the CA in the config
directory only signs the RESP proxy certificates, so the chain build could never
pass. Pin the leaf on first use, keyed by host so multi-cluster templates work.

* Step 1 measured: the lag check is Active-Active only, and does what it says there

Records the real-deployment results (findings section 10): off Active-Active the
lag check fails unconditionally; on it, the stale member flips within seconds of
sync pausing and the tolerance is honoured on a running link. Also the two
integration problems found in Availability/, which mean src/ needs a change.

* Correct the client defaults: Lettuce, Jedis and redis-py all default to 5000 ms

Read from source: redis-py's 5000 contradicts its own docstring's 100. None
of the three checks for Active-Active, and none has a third result state.

* Measure what a probe can discover: not Active-Active over RESP, db_viewer suffices

Active-Active membership has no RESP signal, so it comes from REST (fields=crdt)
or configuration. db_viewer can call everything a probe needs, but unfiltered
bdb/crdb reads expose database passwords even to it, so only filtered requests.
The REST endpoint derives from the configured database hostname.

* TLS reuse, the cross-client survey, and the local form routed by database host

TrustIssuer(leaf) via TlsOptions validates the 9443 call unchanged. No other
client activates lag checks automatically or checks crdt; go-redis routes the
local form through each database host, which makes it truthful, and scopes the
lag verdict to failback only.

* The local form flips under paused sync exactly as the database form does

Same verdict, same timing, stale member only, tolerance ignored while paused.
Its failure code is bdb_endpoint_unavailable, shared with the wrong-node case,
so the choice between forms is about call shape, not information.

* Sketch the lag-aware API for review

A failback-only health check per member rather than a new result value, a
member-scoped probe, and a LagAware factory whose only required input is the
management credential. Compared against Lettuce, Jedis, redis-py and go-redis.

* API sketch: System.Text.Json decided; open questions compared with other clients

Keep LagAware naming and failback (not fallback) terminology; rotating
NetworkCredential callback plus a separate client-certificate option; and keep
the liveness fallback, which no client has, because a dead peer probably makes
the survivor's lag check fail.

* Partition test: the lag check is blind for a minute, then fails on both sides

Partitioning the clusters (tagged iptables rules, self-healing) showed no lag for
~60 s while sync had stopped, then tolerance-governed failure on both members;
a tolerance of 0 is honoured. Supports the liveness fallback, and adds a caveat:
failback in the first minute after a link breaks is not protected.

* AllSuccess: a run of only inconclusive probes is not a pass

The default policy reported Healthy when no probe failed, even if none
succeeded. Harmless for liveness, where both count as connected, but wrong for
any caller that distinguishes them.

* Failback health checks: ask whether a member has caught up before moving to it (SER011)

ConnectionGroupMember.FailbackHealthCheck (and a group default) is consulted
only for members that are not active, and only Healthy makes them eligible. It
never evicts the active member, and when no live member is eligible, selection
falls back to liveness, since a partition leaves every region behind at once.
HealthCheckProbe.Scope lets a probe run once per member rather than per endpoint.

* Notes: core change implemented behind SER011 (SER007 is retired)

* HealthCheck.LagAware: ask Redis Enterprise whether a member is available and caught up (SER011)

A member-scoped probe of the database availability API. Only the management
credentials are required: the REST endpoint derives from the configured host,
the database is discovered with filtered requests only, the lag question is
asked only of Active-Active databases, and no tolerance is sent unless set.
Could-not-ask is Inconclusive, never Unhealthy. System.Text.Json on the
down-level targets; custom certificate validation is unavailable on net461.

* Notes: probe implemented; RawConfig is not public after all

* LagAware: ask the cluster name, and never follow redirects

Found against live clusters: non-master nodes answer the management routes
(discovery, the crdt lookup) with a 307 to the master's internal address, so
deriving the REST endpoint from the database host timed out for three of four
members. Derive the cluster FQDN (strip redis-{port}.), which resolves to the
master, and report a redirect as Inconclusive rather than following it.

* Notes: management routes redirect to the master's internal address

* Fault-injector scenario: refuse to fail back onto a stale Active-Active member

Pauses sync into the preferred member through its cluster's REST API while
writes go to the other, and asserts the group stays off it for 15s although it
is alive, then fails back once it has caught up. The tier now reads every
cluster's credentials, and shares its certificate pin with the probe.

* Notes: tier-3 scenario test passing

* LagAware: client certificates for the REST call

LagAwareOptions.ClientCertificate, with SetUserPfxCertificate and (net6.0+)
SetUserPemCertificate mirroring ConfigurationOptions and sharing its loaders.
Wired into SocketsHttpHandler on .NET and HttpClientHandler down-level; not
supported on net461. Every other client offers mTLS for this call.

* Failback: a member chosen by the liveness fallback is provisional

Other clients never select a member that fails its lag check; the nearest
equivalent that still serves when every member is behind is to leave such a
member for an eligible one as soon as one is available, whatever the weights,
and to keep checking it so it becomes an ordinary active member once caught up.

* Notes: fallback choice is provisional, as decided by client parity

* Move the lag-aware log message to a free event id

main now uses 124 and 125 (#3257); the guard test from #3266 rejects duplicates.

* AnySuccess: a run of only inconclusive probes is not a failure

AnySuccess reported Unhealthy whenever nothing succeeded, even when nothing
failed either - so a probe that cannot tell (a key-write probe on a replica,
say) marked its member unhealthy. Now Inconclusive, matching AllSuccess and
MajoritySuccess.

* Move the lag-aware log message to a free event id (again)

main has since taken 128 as well.

* Failback: a zero FailbackDelay clears a failure in the same clock tick; no ValueTuple

UpdateState compared the last failure strictly before the cutoff. With
FailbackDelay zero the cutoff is now, and on .NET Framework's ~15.6ms clock a
failure and the passing check that clears it often share a tick, holding the
member out for another interval. Compare inclusively. Also replace a named tuple
in the failback-check pass: the library must not reference System.ValueTuple.

* Ship lag-aware failback without an experiment

Remove SER011: the attributes, the constant and NoWarn entry, the [SER011] API
prefixes and docs/exp/SER011.md. It never shipped, so it is not a reserved id.
Also restores main's using directives and encoding in the files that already
existed there, which earlier edits had disturbed.

* Notes: no experiment

* Notes: drop the SER011 history

* Do not abandon a connection attempt before ConnectTimeout has elapsed (#2894) (#3277)

The heartbeat's Connecting branch aborted an in-flight attempt as soon as the
retry policy said a retry was due. With the default ExponentialRetry at half
the connect timeout that is ~2.5s, so any attempt whose name resolution or TLS
handshake took longer than that was killed inside its own ConnectTimeout budget
and reported as a connect timeout. A primary DNS server that does not answer
costs ~5s per lookup on glibc, so on Linux every reconnect attempt died during
resolution and the connection never left Connecting.

Require ConnectTimeout to have elapsed before the retry policy may abort an
attempt. The socket connect already carries its own ConnectTimeout cancellation;
this check bounds TLS and the handshake after it.

* Read HELLO "server" before trusting "version" as the Redis API version (#3279)

* Read HELLO "server" before trusting "version" as the Redis API version (#3278)

Valkey reports its own version as HELLO "version" ("server" => "valkey",
"version" => "9.1.2") while INFO still says redis_version:7.2.4; Garnet
keeps "version" as the Redis API version ("server" => "redis") and adds
"garnet_version". Both the interactive and subscription connections issue
HELLO, so the detected version depended on whether the last reply processed
was HELLO or INFO, and version-gated commands (DELEX for LockRelease) were
sent to servers that don't have them.

Now "version" only sets the Redis API version when "server" is redis; for a
recognised non-Redis server it feeds the product variant instead, and for an
unrecognised server it is ignored. A "<vendor>_version" HELLO field sets the
product variant in the Garnet shape. The in-process test server can emulate
both measured shapes, and a HELLO-only test (INFO disabled) pins the rule
down on RESP2 and RESP3.

* Snapshot the HELLO "version" reader instead of allocating a string

Parse it as a Version (no string) once we know "server" is redis; only the
vendor path, which stores the string anyway, materializes it.

* Parse the HELLO "version" straight from UTF-8

Adds Format.TryParseVersion(ReadOnlySpan<byte>): net10.0 has a built-in
UTF-8 Version.TryParse; elsewhere a Utf8Parser loop over the dotted
components, so no char transcoding and, downlevel, no string. The HELLO
handler uses it via the function-pointer TryParseScalar overload; a parity
test keeps it in step with the char/string overloads.

* Use IsScalar on the snapshot reader instead of a separate flag

* Match the HELLO "server" name case-insensitively

* Pin the raw Version parts (unspecified = -1) for the UTF-8 parser, and the gate normalisation

System.Version treats "7.4" as (7, 4, -1, -1) and "7.4.0" as (7, 4, 0, -1); the
UTF-8 overload must produce the same raw parts as the char/string overloads,
and IsAtLeast/IsEqual must then treat the unspecified parts as zero.

* UTF-8 TryParseVersion: defer to the char overload for exact parity

The goal is identical results to the pre-existing char path for identical
text, whatever System.Version does with it (-1 for unspecified parts,
whitespace and sign tolerance per part), not a tidier parser. net10.0
fast-paths the built-in UTF-8 Version.TryParse; anything it rejects, and
every other TFM, transcodes and calls the char overload. On .NET Framework
that still allocates a string, which is acceptable. The parity test now
covers the whitespace/sign cases.

* Sentinel soft-fail follow-ups: discovery failure as RedisConnectionException, late sentinel init, clear LastException (#3280)

Follow-ups to #3187 (AbortOnConnectFail=false for sentinel), prompted by the
investigation of #3271:

- GetSentinelMasterConnection with a config that has no endpoints of its own
  and nothing discovered threw AggregateException(InvalidOperationException
  "No nodes to consider") from the seed connect. It now throws the discovery
  RedisConnectionException, as it did before #3187.
- A sentinel connection whose initial connect soft-failed never ran
  InitializeSentinel, because only the connect paths called it; such a
  connection never subscribed to +switch-master and tracked failovers only
  through the fallback polling. InitializeSentinel is now idempotent and is
  also reached from a non-first reconfigure that detects a sentinel.
- The discovery failure recorded as LastException at connect time stayed
  there after recovery and was folded into every later "no connection
  available" message. It is cleared once the managed connection is restored.
- Document what abortConnect means for sentinel connections.

* Sentinel: re-resolve the primary when it is demoted on a healthy connection (#3281)

* fix(sentinel): re-resolve the primary when it is demoted on a healthy connection

A sentinel-managed connection notices an in-place demotion of its primary
(INFO replication reports role:slave) but only re-elects among the nodes it
already knows. When sentinel promoted a node at an address the client has
never seen and the +switch-master announcement was missed, there is no
primary among them and nothing asks sentinel again: writes fail with
"No connection (requires writable - not eligible for replica)" until restart.

The sentinel connection's heartbeat now checks, from local state only,
whether each managed connection's sentinel-reported primary is connected
and reporting itself as a replica (or was never added). If so, a recovery
attempt runs off the heartbeat thread, at most one per managed connection:
- it asks sentinel first; no answer: retry on a later heartbeat;
- sentinel names a node held as a connected replica (sentinel has not
  failed over yet, or our view of that node's role is stale): re-read that
  node's role with one INFO replication, rather than reconfiguring every
  node once per heartbeat for as long as sentinel lags (where INFO cannot
  be sent, fall through to the switch rather than ask sentinel forever);
- otherwise switch, as +switch-master would have.
An attempt is skipped while the managed connection is already reconfiguring,
or once either multiplexer is disposed; disconnected primaries are left to
the existing reconnect timer. The heartbeat scan also forgets disposed
children, as the +switch-master handler does.

Tests: an in-process sentinel (InProcessSentinelServer) and a standalone
SetReplicaOf on the toy server. The demotion-to-unknown-address test fails
without the fix (no primary at all) and passes with it; a second test holds
sentinel on the demoted node and checks that sentinel is still polled but
the connection is not reconfigured on every heartbeat, then follows sentinel
once it catches up.

Refs #1891, #2487, #1670

* Review follow-ups: make SwitchPrimary claim the reconfigure gate before reshaping the connection

- SwitchPrimary now claims the reconfigure gate (activeConfigCause) BEFORE rewriting the
  endpoint list or retiring servers, via new TryClaimReconfigure/ReleaseReconfigureClaim and
  a 'claimed' handover parameter on ReconfigureAsync. Previously a reconfigure that started
  between RecoverDemotedPrimary's pre-checks and ReconfigureAsync's own CAS would refuse the
  switch's reconfigure AFTER the connection had been reshaped, leaving retired servers and no
  primary for at least a heartbeat; concurrent switches could also interleave their
  Clear/TryAdd endpoint rewrites. A lost race now touches nothing and the heartbeat retries.
- RecoverDemotedPrimary hands sentinel's already-validated answer to SwitchPrimary
  (reportedPrimary), so the switch acts on the answer the guards evaluated instead of
  re-querying and possibly acting on a different one while sentinel flaps - also removing
  redundant blocking sentinel round-trips from the claimed section.
- A successful demotion recovery clears connection.LastException (as the restore handler
  does) once the reported primary is connected as a primary, so a stale soft-connect
  discovery failure stops polluting timeout messages after the client is healthy again.
- currentSentinelPrimaryEndPoint is now volatile: it is written by SwitchPrimary on
  worker/handler threads and read by the heartbeat demotion check.
- The disposed-child cleanup shared by the +switch-master handler and the heartbeat sweep
  is extracted into ForgetDisposedChild.
- DemotedPrimaryDoesNotReconfigureWhileSentinelStillNamesIt no longer uses wall-clock quiet
  detection plus a fixed observation window with an exact event count: it waits for a
  stretch of three sentinel polls with zero configuration changes, re-baselining on
  stragglers - deterministic on loaded machines, and faster.
- docs/Configuration.md documents how a sentinel-managed connection tracks failovers,
  including demotion-in-place re-resolution.

---------

Co-authored-by: mgravell <marc.gravell@gmail.com>

* Route as a cluster only with a slot map; take HELLO mode/role from the interactive connection only (#3285)

* Route as a cluster only with a slot map; take HELLO mode/role from the interactive connection (#3284)

A proxied deployment can answer HELLO with "mode: cluster" while INFO says
redis_mode:standalone and every CLUSTER subcommand is refused (Azure Managed
Redis with the Enterprise clustering policy). Under RESP3 pub/sub now has its
own connection (#3264), whose handshake is HELLO alone: when that leg alone
reconnected, its HELLO retyped the server as a cluster with no INFO to follow,
the resulting reconfigure put the multiplexer into cluster mode with no slot
map, and cross-slot commands were refused client-side.

- Cluster *routing* now requires a slot map: servers that identify as a cluster
  but give us no map are routed as a single standalone server (new event id
  133). The map is never cleared once populated, so an established cluster
  whose refresh is refused (#3275) keeps routing as one. When a map does
  arrive, stale redundant-primary flags from an earlier election are cleared.
- AnyServer accepts a cluster-typed server while routing as standalone
  (ServerType.IsRoutableAs), so such a server stays a valid target.
- HELLO "mode" and "role" are applied only from the interactive connection;
  the subscription connection does no discovery and must not overwrite
  server-level facts.

Tests: HelloClusterModeUnitTests reproduces the subscription-only reconnect
under both protocols (fails under RESP3 before this change);
ClusterCommandsRefusedUnitTests asserts standalone routing and a working
cross-slot MGET when CLUSTER is refused. The in-process server gains an
overridable HelloModeValue so HELLO and INFO can disagree.

* Derive the slot-map check from allocation; reject inverted or out-of-range slot ranges

The routing decision in ReconfigureAsync needs only "is there any slot map",
not a count. Add ServerSelectionStrategy.IsSlotMapEmpty, derived from whether
the map has been allocated: the map is allocated only immediately before a
write, writers never store null, and nothing clears an entry, so a non-null
map always covers at least one slot. CountCoveredSlots remains for the log
line in the cluster branch, where it has always been.

That derivation is only sound if nothing can allocate without writing, so
UpdateClusterRange now ignores an inverted or out-of-range range instead of
allocating and then writing nothing (or indexing out of bounds). SlotRange
does not validate ordering and the reply-driven paths build ranges straight
from what the server sent.

SlotMapUnitTests covers the rejected ranges leaving the map unallocated.

* Fail connection-dropped commands with RedisConnectionException, never raw or cancelled (#3283) (#3287)

When a connection failed, most queued commands failed with RedisConnectionException
but a few received the raw transport exception (IOException, or the RESPite
writer's InvalidOperationException) or a TaskCanceledException with no token in
play. Three sources, all confirmed with the reporter's repro:

- RecordConnectionFailed builds its detailed RedisConnectionException only for
  the bridge's current connection and only on the first report; a repeat report
  (the write path and the read loop both reporting), or one against a connection
  the bridge has already replaced, drained the queue with the raw inner
  exception - or with null, which the result box turns into a cancellation
  (every EOF path in the read loop reports with no exception).
- The write-lock catch fails the message properly and then reports the
  connection, which drained the same still-queued message again with the raw
  exception; last write wins.
- FailDueToNoConnection, TimedOutBeforeWrite and the fail-fast bin completed the
  message via Cancel(); ExecuteAsyncImpl then races to fault the task with the
  real exception, and the box's thread-pool continuation sometimes wins. A batch
  has no caller translating the result at all.

Fix: the drain always uses a RedisConnectionException (wrapping whatever it was
given, with the failure type), and the bridge's refusals set the exception the
caller path would have built instead of cancelling. Message.Cancel itself is
unchanged; transactions rely on it for aborted conditions.

ConnectionFailureExceptionTests covers the drain (null and IOException inner,
on a bridge-less connection, i.e. the non-current path) and the no-connection
refusal observed at the message's own task. Reporter's repro, 90 s: 884
TaskCanceledException + 415 InvalidOperationException before; none after.

Fixes #3283

* Tests: tolerate clock skew on absolute hash-field expiries; wait for quiescence before asserting backlog counters (#3288)

Two of the recurring Windows CI flakes. HashFieldTests' DateTime-based expiries are computed on the client clock and
read back on the server's (in WSL), and the TTL came back as 7200001ms against a hard 7200000 ceiling: five tests in one
run. BacklogTests.TotalOutstandingIncludesBacklogQueue asserted TotalOutstanding == 0 the instant the last backlogged
ping completed, while the reconnect's handshake traffic was still in flight (seen as 7 and 1; six failures in the last
twelve days).

* Connection groups: a member answering LOADING triggers an immediate re-probe and re-select, like a breaker trip (#3289)

The group's fast-path only reacted to ConnectionFailureType.CircuitBreaker. A server that is loading fails every
reconnect during the handshake, so commands routed to it die in the backlog without a reply, and the breaker only
observes replies - it can never be what moves traffic away. This is the shape of the recurring Windows CI failure of
WithRetry_FailsOverBetweenGroupsOnLoading (six attempts in twelve days, always 20 x 5000ms backlog timeouts): when the
heartbeat rather than the test's command met the first LOADING, nothing ever failed over.

LOADING now joins CircuitBreaker in the fast-path; the health check still decides where traffic goes, so a member whose
probe passes is kept (WithRetry_WhenFailoverNeverArrives_KeepsRetryingSameMember). New LoadingRerouteTests fails
without the change.

* IBatch: complete commands refused by a poisoned connection; divert a batch's tail on reconnect (#3286)

* Complete a message refused by a poisoned connection; divert a batch's tail on reconnect (#3282)

Since #3205 a connection whose write faulted is poisoned and refuses further
writes, so that no reply can be matched to the wrong caller. That refusal
returned WriteFailure without completing the message. Single commands were
unaffected, as ExecuteAsyncImpl/ExecuteSyncImpl translate a non-success result
into an exception for the caller; but RedisBatch.Execute writes a list via
PhysicalBridge.TryEnqueue, which does not act on the result, so once one
message of a batch faulted the connection, the rest of that batch was neither
queued nor completed and its tasks were awaited forever (3.2.0 to 3.4.5).

- WriteMessageToServerInsideWriteLock: the IsWriteFaulted refusal now fails and
  completes the message, as every other refusal to write already does
  (FailDueToNoConnection, TimedOutBeforeWrite, QueueOrFailMessage, the
  write-lock catch, the transaction sub-command loop).
- TryEnqueue(list): once NeedsReconnect is set part-way through the batch, the
  remaining messages take the same path a single command would at the top of
  TryWriteSync - backlog or fail, per policy - instead of being written to a
  connection known to be dead.

BatchWriteFailureTests injects a write failure mid-batch under both backlog
policies and asserts that every task completes and that any successful result
is real. The reporter's repro (400 batches in flight, CLIENT KILL in a loop)
hung within 13 s on main and ran 6.6 M batches in 120 s with this change.

Fixes #3282

* Batch: route the tail to the backlog when the held connection is poisoned, not just while the reconnect flag is set

The reconnect flag is cleared as soon as TryConnect starts a replacement connection, on another thread; a batch
still holding the previous PhysicalConnection could then write its remaining messages to it and have them refused
with WriteFailure. Seen on the Windows CI runner (2 of 3 attempts); reproduces locally 12/25 pinned to two cores.
Also make the test's fail-fast variant wait for the reconnect before its verification read, which is the other
way the two-core run failed.

* Tests: don't race the connect's own reconfigures in AllClusterCommandsRefusedIsQuiet (#3290)

A freshly connected multiplexer fires fire-and-forget reconfigures of its own
(connection-restored per physical connection, autoconfigure field changes), and
ReconfigureAsync returns false while one is in flight. The test asserted on
that return value directly, so it lost the race on the Windows runner
(main @ 74c52bf, net10.0/RESP3).

Hoist the retry loop HelloClusterModeUnitTests already used for this into
Poll.ReconfigureAsync and use it in both tests; dump the captured log after
the assertions so a future failure shows what was actually logged.

* Send primary-only commands to the primary when the caller prefers a replica (#3291)

* Send primary-only commands to the primary when the caller prefers a replica

KeyDelete, KeyExpire (milliseconds), LockExtend and LockRelease pick a server up front to check its features. With PreferReplica that picked a replica, and the primary-only message was then rejected with 'Command cannot be issued to a replica'. GetFeatures now upgrades the flags the same way Message does.

Fixes #2821

* Mirror Message.SetPrimaryOnly exactly; explain the PEXPIRE expiry in the test

---------

Co-authored-by: Marc Gravell <marc.gravell@gmail.com>

* Give multi-message sub-commands and injected prefix commands a write time (#3292) (#3293)

Sub-commands of an IMultiMessage (WATCH, condition reads, MULTI, the queued
operations; also SCRIPT LOAD and PTTL) are written inside the lock without
passing through SetEnqueued, as are the SELECT/READONLY/ASKING/PREPARE
prefixes injected ahead of a command. With a zero write time the heartbeat
sees them as overdue by the process uptime; since #2965 such no-box messages
count as sync timeouts, and together with an idle connection that satisfies
the dead-socket heuristic while the reply is still in flight.

Sub-commands now inherit the parent's write time (sharing its budget,
including backlog time); prefix commands are stamped when written, via a
single helper replacing four identical blocks.

* Refresh package pins: 10.0.13 servicing family and tooling (#3294)

Shipped BCL dependencies move 10.0.5 -> 10.0.13 (System.IO.Hashing 10.0.12 -> 10.0.13);
test/tooling packages to current. Removes the unreferenced PerformanceCounter pin and
documents that the StackExchange.Redis 2.13.17 pin is the baseline projects' previous-major
comparison, not a lagging version.

Analyzer.Testing 1.1.4 brings NuGet.Packaging 7.0.3 -> System.Security.Cryptography.Pkcs 9.0.6,
which NuGet audit rejects; a direct Pkcs 10.0.13 reference in Build.Tests lifts it.

Held back for separate PRs: xunit.v3 4.x (needs Microsoft.Testing.Platform 2.5+, which drops the
VSTest bridge under the .NET 10 SDK, plus discoverer/attribute changes) and StyleCop 1.3-beta
(92 new rule hits in src).

* xunit.v3 4.0 on Microsoft.Testing.Platform (#3295)

* xunit.v3 4.0 on Microsoft.Testing.Platform

xunit.v3 4.x depends on xunit.v3.mtp-v2, which needs Microsoft.Testing.Platform 2.5+, and
that drops the VSTest bridge under the .NET 10 SDK. So: global.json selects the MTP runner
for dotnet test, the four test projects set UseMicrosoftTestingPlatformRunner and stop
referencing the VSTest adapters (Microsoft.NET.Test.Sdk, xunit.runner.visualstudio,
xunit.v3.runner.console), and CI.yml/build.ps1 use the MTP dotnet test shape (--project /
--solution, --report-xunit-trx; the GitHub Actions reporter self-registers as an extension).

xunit 4 fallout: the per-protocol TheoryDiscoverer overrides the new 6-parameter
CreateTestCasesForDataRow; the fault-injector tests use [Parallelization(Mode = None)];
xUnit1069 is suppressed for the round-trip tests, whose Timeout is a hang guard; net481
regains System.Reflection.Metadata and Microsoft.CSharp, previously transitive via Test.Sdk.

* CI: the Ubuntu composite action also moves to the MTP dotnet test shape; enable the GitHub reporter

--report-github is off by default under Microsoft.Testing.Platform (the VSTest logger was on
by name), so both jobs ask for it explicitly, with the summary limited to failures as before.

* Follow-ups for the MTP runner: analyzer tests join the solution, build.ps1 wording and framework, docs

Build.Tests was never in StackExchange.Redis.slnx, so dotnet test --solution skipped it.
The -NetCoreOnlyTests path moves to net10.0: two test projects have no net8.0, and the
runner fails a project lacking the requested framework instead of skipping it. AGENTS.md,
the skills and the fault-injector README use the --project / --filter-class forms.

* Use the platform TRX reporter; observe the test-side tasks the new runner reports as unobserved

xunit 4.0.2's --report-xunit-trx wrote a truncated file for the 6.8k-test run (ends mid-element;
dorny/test-reporter: 'Invalid XML'), locally as well as in CI. Microsoft.Testing.Extensions.TrxReport's
--report-trx produces a complete report (validated: parses, 6828 UnitTestResult elements).

Microsoft.Testing.Platform also prints every UnobservedTaskException at the end of the run, which
VSTest never showed. All of the ones seen are in test code, not the library: the in-process server's
background work logging to xunit after its test ended (xunit v3 throws 'no currently active test');
BatchWriteFailureTests never reading its Task.WhenAll's exception; TcpForwarder's losing copy
direction. Each now observes or swallows its own failure.

* VectorSetInfo: vset-uid is a zero-based counter, so do not assert it is non-zero

Measured on a fresh test server: the first vector set created after startup reports vset-uid 0,
the next 1, and so on. CI starts a fresh server every run, and the xunit 4 discovery order makes
VectorSetInfo(Binary) under RESP2 the first test to create one, so the old assertion failed there
and nowhere else. Check the parsed value against the raw VINFO reply instead.

* StyleCop.Analyzers 1.2.0-beta.556 -> 1.3.151-beta, and the tidy-up it asks for (#3296)

New rule hits, all fixed rather than suppressed: SA1407 (parenthesise comparisons combined with the
non-short-circuit & and |), SA1143 (multi-line conditionals use the expanded layout), SA1504 (an
accessor pair is all single-line or all multi-line), SA1315 (override/implementation parameter names
match the member they implement), SA1653 (<content> on secondary partial declarations), plus single
SA1117/SA1413/SA1500 sites. 46 sites in RESPite, 175 in StackExchange.Redis, 3 in RESPite.Benchmark.

SA1315 found a real bug: KeyPrefixedDatabase.SortedSetRangeAndStore (the sync, key-prefixed overload)
declared its first two parameters as (destination, source) against the interface's (source,
destination) and forwarded them by name, so it read from the destination and wrote into the source.
Regression test: WithKeyPrefixTests.SortedSetRangeAndStoreUsesTheRightKeys.

Public API: AzureOptionsProvider/AzureManagedRedisOptionsProvider.AfterConnectAsync rename 'muxer'
to the base's 'multiplexer' (recorded in PublicAPI.Unshipped); every other rename is on an internal
type or an explicit interface implementation.

* Port main's fixes onto the new core by intent (merge follow-up): #3291, #3279, #3285, #3275, #3281, #3289, #3287, #3286

- #3291 (#2821) primary-only + PreferReplica: RespConnectionManager.EndpointForAny rewrites PreferReplica to DemandMaster for a primary-only command before the selector sees the flags, as Message.SetPrimaryOnly did; DemandReplica still fails. KeyTests.PrimaryOnlyCommandsWithPreferReplicaRunOnThePrimary passes.
- #3279 (#3278) HELLO "server" before "version": HelloHandler reads the fields through HelloFieldMetadata; "version" is the Redis API version only when "server" is redis (absent counts as redis), a "{vendor}_version" names the product, a known non-Redis server's "version" is its own and the API version comes from INFO or the configured default, an unknown "server" is logged; the handshake applies the product variant from HELLO. ProductVariantUnitTests and ValkeyUnitTests pass.
- #3285 (#3284) route as a cluster only with a slot map: RespTopology.RoutesBySlot now also wants a map, so a server that announces "cluster" while refusing CLUSTER subcommands routes as standalone (cross-slot commands go to the proxy) until a map arrives from the handshake, a refresh or a -MOVED; the refresh sweep asks SLOTS whenever the deployment may be a cluster (NeedsSlots). HELLO mode and role are published only by the interactive connection (or a reply that carried slots), so a subscription reconnect's HELLO alone cannot flip the type. RespMultiplexerExecutorTests give their topology a map where they declare a cluster; HelloClusterModeUnitTests subscribes first, since this core opens the subscription connection on first use.
- #3275 follow-ups: a refused CLUSTER SLOTS is logged by the handshake, and GetEndpointsFromClusterNodes asks for the SLOTS view when only NODES is cached (ReadSlotsViewAsync), so a server that keeps refusing says so on every pass; ClusterViews is a struct, not a tuple (SanityChecks.ValueTupleNotReferenced).
- #3281 sentinel demotion on a healthy connection: the heartbeat re-reads each established interactive server's role (ROLE via RespConnectionManager.TryRefreshRole), paced by ConfigCheckSeconds with the existing exponential ramp (ServerEndPoint.RoleCheckDue/OnRoleChecked), which is what the shipped bridge's CheckInfoReplication did and what RecoverDemotedPrimary needs. Also found: the new core validated commands against RawConfig.CommandMap rather than the multiplexer's effective map, so a sentinel multiplexer (CommandMap.Sentinel) refused SENTINEL when the caller's map lacked it; every RawConfig.CommandMap read in the core now uses the effective map. SentinelPrimaryDemotionUnitTests pass; SentinelTests against the real sentinels still pass.
- #3289 LOADING steers a connection group away: the endpoint executor's outcome observer reports the first LOADING reply of an episode through ConnectionFailed(Loading), re-armed by a later success. Deliberately WITHOUT dropping the connection, which the shipped core did: a loading server answers everything with LOADING, its handshake included, so the drop only bought a reconnect loop, and a retrying caller then found the member mid-dial and was moved off it. LoadingRerouteTests and RetryEndToEndTests pass.
- #3287 (#3283) and #3286: this core already fails connection-dropped commands with RedisConnectionException and completes a poisoned batch; ConnectionDroppedExceptionTests pins both from the public surface, replacing the two tests dropped in the merge because they drove v3 internals.
- #3277 (#2894), #3273, #3293: their tests (ConnectionAttemptUnitTests, TransportCloseTests, MultiMessageWriteTimeTests) pass on this core as merged; nothing to port.

Main suite net10.0: 7893 passed, 0 failed, 167 skipped.

* StyleCop.Analyzers 1.3.151-beta on v4 (main #3296 reached the new core): the 79 sites the shared tidy could not see

Held at 1.2.0-beta.556 through the main merge so that the merge and the intent ports could be judged on their own; this takes main's pin and answers what 1.3 says about v4-only code. All 79 in src/StackExchange.Redis:
- SA1653 (54): partial types documented in every part. The primary file keeps its <summary>; the other parts (RedisDatabase.*.cs, the Groups *.Methods/*.Types/*.Sort parts, RespClientCache.Slabs.cs) use <content>, as main's tidy did for its own partials.
- SA1143 (11): ternaries laid out with ? and : each starting a line; two multi-line conditions (PFCOUNT's replica safety, XREADGROUP's read-only category) moved into a named local first.
- SA1504 (8): a single-line getter beside a multi-line setter/init made block-bodied.
- SA1315 (5): IInternalConnectionMultiplexer implementations take the interface's parameter names (endPoint, type).
- SA1407 (1): parenthesised the comparison beside a non-short-circuit &.

No behavioural change. Main suite net10.0: 7893 passed, 0 failed, 167 skipped.

* Merge fix-up: restore v4's CI.yml (the --theirs resolution had replaced the whole file), and the report-github reporter for the two small test projects

- CI.yml: the merge took main's file wholesale for a one-hunk conflict, dropping v4's Windows server setup - the diskless replica load and the wait for every replica to sync and be listed by CLUSTER SLOTS - as well as the v4 branch triggers and the 30-minute test step timeout. Without the replica wait, no replica on the Windows job ever syncs, CLUSTER SLOTS lists none, and the replica-routing tests fail ("only 0 slots carried replicas"). This is v4's file with main's one change applied: the Microsoft.Testing.Platform `dotnet test` invocation. VSTest's --blame-hang has no platform equivalent without the HangDump extension; the step timeout stays as the backstop.
- RESPite.Tests and Build.Tests: the CI action passes --report-github to every test project, which is the GitHubActionsTestLogger package's platform extension; the merge had dropped that reference from these two, so their steps exited 5 (invalid arguments, zero tests ran). Verified locally with the action's exact flags: 1960 and 188 pass.

---------

Co-authored-by: Diogo Martins <165835485+MDA2AV@users.noreply.github.com>
Co-authored-by: rebaseandpanic <rebaseandpanic@proton.me>
Co-authored-by: AsadIqbal-Software Engineer <asadiqbaldst2023@gmail.com>
…e) and Prefixes as RedisKey (#3304)

* client cache: configured once per connection group (MultiGroupOptions.ClientCache, inherited; a member's own is refused), and Prefixes as RedisKey over a private byte snapshot

- MultiGroupOptions.ClientCache / Builder.ClientCache: the one place a group's cache is set. Each member keeps a cache of its own and the group answers from the active one, so members must cache alike or a failover would change what callers get; a member whose ConfigurationOptions.ClientCache is set is refused (at connect and at AddAsync). Plumbed like the group circuit breaker: alongside the configuration, never written into it; a sentinel member gets a clone, since its cache is built with the connection, and a sentinel connection itself never builds a cache.
- CacheOptions.Prefixes is IReadOnlyList<RedisKey>: keys are bytes, and a prefix that is not valid UTF-8 had no spelling; a key made with WithKeyPrefix is taken whole. Rendered once to a private byte[][] that both the matcher and the handshake's PREFIX arguments read (no re-encoding on the way out); nothing of the caller's list or arrays is kept, and the getter hands out copies. Existing string call sites compile unchanged.
- GetClientCache remark: on a group it is the active member's cache at the time of the call.
- Tests: GroupCacheTests (group-level configuration, late joiner, member cache refused), prefix snapshot/bytes/prefixed-key tests. Docs: Failover.md row and section, ClientSideCaching.md "Connection groups", design ledger.

Main suite net10.0: 7898 passed, 0 failed, 167 skipped.

* docs: note the group-level cache in the caching overview and Configuration.md, and describe Prefixes as RedisKey values copied when set
…ng back to keyspace notifications (#3305)

* client cache: CacheTrackingMode.None and CacheTrackingMode.KeyspaceNotifications

Two invalidation strategies that do not use CLIENT TRACKING, and so do not
need RESP3:

- None: nothing is negotiated; entries are retired by TimeToLive and by
  local writes alone. Prefixes, if given, only bound what is cached.
- KeyspaceNotifications: the cache subscribes to __keyspace@*__:{prefix}*
  per prefix (or __keyspace@*__:* without prefixes) and invalidates the
  named key on any event. Multi-node channels, so cluster subscriptions go
  to every node, as keyspace notifications require. If the server has
  notifications disabled the subscription is silent and the cache behaves
  as None; that is documented, not checked. Prefixes are a hard boundary on
  what is cached (as under Broadcast) and must not contain glob characters.

The handshake skips CLIENT TRACKING (and its RESP3 requirement) for both.
The subscription is made after connect in both connect paths; a failure to
establish it is logged rather than failing the connect, since it is
registered first and restored with the subscription connection.

Claude-Session: https://claude.ai/code/session_01DUpQ8hTYNcGjwf1eA7R6Ss

* client cache: CacheTrackingMode.None is the zero value

None = 0 and Default = 1, the rest shifted up one (all unshipped).
CacheOptions.TrackingMode is initialised to Default, so an unconfigured
cache still gets the library's choice; only a zeroed enum is None.

Claude-Session: https://claude.ai/code/session_01DUpQ8hTYNcGjwf1eA7R6Ss

* client cache: Default falls back to keyspace notifications when tracking is unavailable

Under CacheTrackingMode.Default only, a cache that cannot get CLIENT TRACKING
switches to KeyspaceNotifications rather than failing the connection: no
RESP3, CLIENT disabled in the command map (known at construction), or any
server error answering CLIENT TRACKING. A named mode still fails loudly.

The switch is cache-wide, one-way and at most once: the mode-dependent state
(CLIENT CACHING frames, opt-in/by-prefix gates) is one immutable object
swapped by CAS, so the frames a non-tracking server would reject stop at
once. It flushes, starts the keyspace subscription (now idempotent and keyed
off the mode in force), and logs a warning (event 139). When CONFIG GET is
permitted and notify-keyspace-events raises nothing usable, a second warning
says so (event 140) - a diagnostic, never a refusal.

ClientCache.EffectiveTrackingMode (via GetClientCache()) reports the mode in
force. Resp2WithACacheRefusesLoudly now names Tracking explicitly, since an
unset mode over RESP2 falls back instead.

Claude-Session: https://claude.ai/code/session_01DUpQ8hTYNcGjwf1eA7R6Ss
mgravell and others added 9 commits October 29, 2026 14:59
…nge)

PR #3298 brought main's 19 commits to 2026-10-23 into v4 but was squash-merged (da8181e), so
git lost the merge parent and still counted those commits as unmerged. A trial three-way merge of
aa1e5c0 onto v4 reproduces v4's tree exactly, apart from re-adding BatchWriteFailureTests and
ConnectionFailureExceptionTests, which #3298 dropped on purpose (they drove v3 internals;
ConnectionDroppedExceptionTests covers both from the public surface). This commit takes v4's tree
and adds aa1e5c0 as a parent so future merges of main start from where they should.
…nore)

- #3303: the CycleBuffer fix itself was already in v4 (recycle resets StartTrimCount); only main's second
  test, DiscardDuringWriteKeepsLeasedBytesAfterARecycledTrimmedSegment, is new here.
- #3306: .claude/* ignored except .claude/skills/, superseding v4's .claude/worktrees/ line.
…d, the reply held until it validates (#3308)

* High-integrity mode restored: a sequence check (ECHO <token>) behind every command, the reply held until it validates

Lost with the old core: ConfigurationOptions.HighIntegrity, ResponseIntegrityFailure, the docs and the
HighIntegrity*Tests classes survived, but nothing assigned a token or validated a reply, and the test
classes passed vacuously because they only re-ran suites with the flag set.

Rebuilt as the dual of the prefix frame, at the same choke point. RESPite: RespConnection.SequenceCheckHead
makes Stage write the check (the owner's rendered ECHO head, a 4-byte little-endian per-connection counter,
CRLF) behind every request whose message is SequenceChecked, with one more pending entry naming
RespSequenceCheckSink; the reader holds a checked reply (a reservation on the receive buffer) until the
next in-band frame is the bulk string carrying the expected token, then delivers it - or faults it and
closes the connection with CreateSequenceCheckFault (a virtual). A handed-off reply (redirect, +QUEUED)
is not held but its check is still validated, so the counter stays in step across a MOVED. Close faults a
held reply like anything else pending.

SE.Redis: RespConnectionManager enables it after the handshake, interactive connections only, with ECHO
rendered through the command map (a map that disables ECHO refuses the connection rather than checking
nothing); RespClientConnection substitutes RedisConnectionException(ResponseIntegrityFailure), so the
failed command, the closed connection and ConnectionFailed all carry the shipped type; RespTransaction
marks MULTI, WATCH, the condition probes and the queued commands unchecked, so EXEC/UNWATCH validate the
span - v3's rule. The token lands in the operation's diagnostics (hi-token).

Tests the mode never had on either core: RespSequenceCheckTests (wire shape, the hold, wrong token,
missing frame, push between reply and check, split receive, buffer roll under a held reply, hand-off,
prefix + check, close while held, per-life reset) and HighIntegrityWireTests (EXEC-only checking in a
transaction, every member of a batch, the failure type and close); HighIntegrityMovedUnitTests now
asserts that checks happened on every interactive connection.

Docs: design/command-flow.md describes both framing extras (prefix, sequence check) in the trace and
adds end-to-end bullets for client-side caching (modes, opt-in/out, invalidation sources, groups) and
high-integrity mode; docs/Configuration.md says how the mode works and fails; ledger status entry.

* sequence check: test the mode before peeking the queue, so a connection without checks pays nothing per reply

* ledger: high-integrity mode measured (off path unchanged; the mode's cost on v4 and v3; the buffer-roll diagnosis and the look-ahead next step)

* sequence check: look ahead for the check before holding the reply, so the common case (reply and ECHO in one read) takes no buffer reservation

* sequence check: the checked bit rides in the request's pending entry (no second entry per command, no sink type), and the check is staged in one write

* ledger: the high-integrity mitigation measured (allocation per op back to the off path; the remaining cost is the mode's second frame each way)
…ebug builds compile (#3320)

* Parenthesize a Debug-only assertion that fails SA1407 (same as #3314 on main)

* Client cache: replace a TimeToLive-expired entry on the next fill

The fill path replaced a resident entry only when it was a refresh or the
entry had been invalidated. IsValid tracks invalidation generations only,
so an entry that had aged past TimeToLive still read as valid: TryAdd failed
against it, the fill was written off as redundant, and the key could not be
re-cached until the sweep (10s by default) removed the corpse. A steadily
read key therefore missed on every read from expiry until the sweep; with a
5s TimeToLive that is half of every ten seconds at a 0% hit rate.

An expired entry is now treated as dead in the same way as an invalidated
one. Regression test runs with sweeping disabled so only the fill path can
make it pass.

Fixes #3318

* v4 Debug build: remove the string-to-RedisValue conversions the Debug-only self-check flags

All writes, so .AsRedisValue() where the value is computed (AUTH, HELLO,
CLIENT SETINFO/SETNAME, EVALSHA hash, CONFIG GET pattern) and RedisLiterals
where it is a literal (HOTKEYS START arguments; CPU, DURATION, METRICS, NET,
SAMPLE and START added to the table). CoreBench gets the same NoWarn as its
baseline. Release builds were unaffected; local Debug builds of v4 have been
red on this since early October.
…m the sequence check (#3319)

ASKING and CLIENT CACHING are both one-shot server flags meaning "the next
command", and each is the other's next command: the server clears either
after any command that is not the one that set it (resetClient, outside
MULTI), so no order keeps both. Measured on Redis 8.9 with a slot set
IMPORTING: ASKING, CLIENT CACHING YES, GET answers -MOVED (a second
redirect, so the command fails); CLIENT CACHING YES, ASKING, GET is served
but never tracked (a stored reply would be stale until eviction); and
ASKING, ECHO, GET answers -MOVED too, so high-integrity mode broke every
ASK redirect.

The redirect is the half that cannot be given up. TryResendAsking now drops
the operation's prefix (the operation remembers and stamps PrefixDropped on
its reply; the cache refuses such a reply and counts it as RefusedUntracked)
and marks the ASKING operation unchecked, so the pair's one check lands
behind the command as a transaction's does behind EXEC. MOVED is unaffected.

Tests assert the exact bytes on a fake transport for both cases, plus a
cache-level test; the in-process server cannot produce an ASK.
…, batch profiling, API promotion (#3322)

* Make the dedicated subscription connection lazy (#3310)

The dedicated pub/sub connection (always under RESP2; under RESP3 unless
SharedSubscriptionConnection is enabled) was opened eagerly as part of
activating every endpoint, so a client that never subscribes to anything
still paid for a second socket per node, and a second reconnect per node
on every failover. Seen on AMR, whose options provider disables the
configuration channel: 3.4.x showed two RESP3 connections where 3.0.x
showed one.

ActivateServer now opens that connection only when there is something to
subscribe to straight away: a configuration channel, or subscriptions that
already exist (and may need to move to this endpoint). Otherwise it is
created on first use, via the existing GetBridge paths; with the default
backlog policy the first SUBSCRIBE queues until it is up.

The connect wait ("second leg") and the maintenance handoff deadline
treated an absent subscription bridge as a disconnected one, which would
have left connects hanging; they now consider a connection that has not
been asked for as not required. The endpoint-state connect log says
"NotRequired" for that case instead of "Disconnected".

Tests: AzureManagedRedisConnectionCount now asserts one connection before
pub/sub and the expected count after; a new test covers the eager case
when a configuration channel is set; two tests that relied on the eager
subscription connection now subscribe first.

* Command coverage ledger and audit skill (#3311)

* Command coverage ledger and audit skill

Adds docs/CommandCoverage.md: every upstream Redis command the library does
not expose as a typed API, grouped and with a recorded decision (policy,
not applicable, deferred, todo, deprecated, enum-only, implemented). Seeded
from a diff of redis/redis src/commands (477 specs, @6a5fee67) against the
RedisCommand enum and source; it carries forward the dispositions from
#2055 (blocking is policy, Functions and ACL are deferred) and replaces
that hand-maintained list.

Adds the audit-command-coverage skill and its script, which re-runs the
diff on request and reports only what changed: new upstream commands with
no decision, ledger rows the code now covers, rows the spec no longer has,
and enum members the spec does not know. The script exits non-zero when
the ledger needs editing.

Closes #2055.

* Ledger: CLIENT TRACKING is v4, cluster/COMMAND introspection is administration, CLUSTER SHARDS note corrected

* Ledger: CLIENT PAUSE/UNPAUSE are operator tooling, not applicable

* Update RedisType.Bitmap and the keyspace-event tokens to reflect current server values (#3312)

* Object-type metadata for native bitmaps (redis/redis#15331), plus the tokens that diff turned up

RedisType gains Bitmap ("bitmap", the TYPE reply for a native roaring
bitmap, 8.12) and Gcra ("gcra", already in the server's type-name table).
KeyNotificationType gains SetBit (emitted under class $ for string bitmaps
and class b for native ones) and the other event names the server emits
that the enum lacked: arinsert, armset, arring, arseek, gcra, pfadd,
sexpire, spersist, sinterstore, zrangestore, and keymiss (class m, side
effect group).

Fixes the Copy token: the server has only ever emitted "copy_to", so the
"copy" token never parsed and a RedisChannel.KeyEvent(KeyNotificationType.Copy)
subscription never fired. The member and its value are unchanged.

Both parsers are generated from [AsciiHash]; tests extend the token tables.

* Drop Gcra: the type was withdrawn before release, so there is nothing for the token to match

* Add sunionstore/sdiffstore events (from the keyspace-notifications docs); drop the history comment on Copy

* Fix ConnectionGroupMember string-ctor dropping the name (#3314)

* Forward the name argument in ConnectionGroupMember's string constructor

The string overload chained to the ConfigurationOptions constructor without passing `name`, so every
member built from a configuration string (the idiom used throughout docs/Failover.md) was named after
its first endpoint by Init() instead of the name the caller gave it. Regression test added.

* Parenthesize a Debug-only assertion that trips SA1407

`endSegmentLength == 0 & endSegmentCommitted == 0` in DebugAssertValid sits inside `#if DEBUG`, so CI
(Release) never compiles it and never saw the StyleCop error; any local Debug build of RESPite failed
with TreatWarningsAsErrors. Introduced by ee47e53 after the StyleCop bump in aa1e5c0.

* Parse unbracketed IPv6 ip:port from the server (#1422) (#3307)

* Parse unbracketed IPv6 ip:port from the server (#1422)

CLUSTER NODES, MOVED/ASK and CLIENT LIST write IPv6 addresses without
brackets, i.e. "2a06:4e80:3:a::a:7000". The RFC 3986 parser used for
configuration strings reads that as a (valid!) eight-group address with
no port, so cluster nodes ended up as "[2a06:4e80:3:a::a:7000]:0" and
redirections and client addresses were equally wrong.

Add Format.TryParseServerEndPoint for the server's "ip:port" form: when
there is no ']' and more than one ':', split at the last colon and
require both halves to parse; anything else goes to the existing parser
unchanged, so configuration parsing is not touched. Use it at the three
sites that receive server-emitted text.

The sentinel +switch-master handler gets host and port as separate
tokens and was gluing them back together with ':' before parsing; pass
them to the (host, port) overload instead.

* Parse unbracketed IPv6 in MOVING/SMIGRATED endpoints

ParseMigrationEndPoint used the configuration-string parser, so an unbracketed IPv6 address parsed as an address with port 0, which the placeholder check then discarded. Use TryParseServerEndPoint, as the other server-emitted sites do; tests for the MOVING successor and SMIGRATED source/target.

* Profile commands sent in a batch (#2175) (#3313)

Co-authored-by: mohsinaliwork686-rgb <229973101+mohsinaliwork686-rgb@users.noreply.github.com>

* public-api.py: apply *REMOVED* at promotion; promote the API surface since 3.4.0 (#3316)

* public-api.py: apply *REMOVED* entries at promotion instead of copying them

The analyzer records a vanished shipped signature as a *REMOVED* line in
the unshipped file. Promotion moved every unshipped line across verbatim,
so a promotion after any such change would have left the shipped file
with both the dead signature and a *REMOVED* copy of it, and the analyzer
would then fail the build on the dead one (RS0017).

Promotion now consumes the marker: the named signature is dropped from the
shipped file, the marker is not carried over, and a marker that names
nothing shipped is an error. A *REMOVED* line already sitting in a shipped
file is also reported. The "+N promoted" note gains "-N removed", and an
unshipped file holding only removals is still emptied.

* Promote the shipped API surface: everything since 3.4.0

eng/public-api.py --promote. Fifty-two StackExchange.Redis entries (the
lag-aware availability surface, FailbackHealthCheck, HealthCheckProbeScope,
the 8.12-independent RedisType and KeyNotificationType tokens) and the
net6.0 SetUserPemCertificate overload move to shipped; the three parameter
renames from #3296 (muxer -> multiplexer on the Azure providers'
AfterConnectAsync, other -> obj on RESPite AsciiHash.Equals) are applied
rather than copied. Every unshipped file is now empty.

* Split third-party notices out of LICENSE so GitHub detects MIT (#3317)

* Split third-party notices out of LICENSE so GitHub detects MIT

The LICENSE file carried ~1.1KB of third-party notes after the MIT text,
which drops it below licensee's similarity threshold; GitHub reports the
repo license as "Other" (SPDX NOASSERTION). Keep LICENSE as the bare MIT
text and move the notes, verbatim, to THIRD-PARTY-NOTICES.md.

* Correct the third-party notices

- Redis: BSD-3 only through 7.2; RSALv2/SSPLv1 from 7.4, AGPLv3 added in
  8.0; point at redis.io/legal/licenses (the old /topics/license URL now
  redirects to the docs landing page).
- redis-doc: repo is archived under redis/redis-doc; note that redis/docs,
  its successor, is CC BY-NC-SA for newer content.
- Drop the Redis-64 and BookSleeve notes: neither is part of the
  development solution any more.
- Add the code actually incorporated from other projects (dotnet/runtime,
  dotnet/corefx, aspnet/BasicMiddleware) and the test-only redis-sharp copy,
  none of which the old notes mentioned.

---------

Co-authored-by: Mohsin Ali <mohsin.ali.work686@gmail.com>
Co-authored-by: mohsinaliwork686-rgb <229973101+mohsinaliwork686-rgb@users.noreply.github.com>
… dead List<RedisValue> builders go (#3321)

The v4 model is the interpolated writer: conditional clauses belong in an
operand struct that writes straight into the frame, not in a RedisValue
list spliced in as an array. HOTKEYS START and CLIENT KILL were the last
two command paths still rendering the old way.

- HotKeysStartArgs becomes HotKeysStartOperand, with the tokens as
  pre-framed RespLiterals; the STOP/RESET/GET subcommands move to
  fragments too. HotKeysWireTests pins the clause order byte for byte.
- ClientKillFilter.ToList/ToArray become ClientKillFilter.Operand - still
  the one encoding both public spellings of CLIENT KILL share - and
  ClientKillCount takes the operand instead of a rendered span. The test
  that compared against ToList now asserts the rendered frame, which also
  covers the replica/slave spelling both ways.
- Dead since the old core was deleted, called by nothing: the List-based
  GeoSearchShape.AddArgs overloads, the GeoRadiusOptions.AddArgs
  extension, and StreamIdempotentId.WriteTo(RedisValue[], ref int).
…e; treat a client-side replica-write refusal as a slot-map staleness signal (#3324)

* The library's own topology refresh must not be refused by the AllowAdmin gate, and a write refused because its routed endpoint is a replica marks the slot map suspect

Found investigating #3323 (which does not reproduce on 2.x/3.x): after a cluster
failover, a v4 client without allowAdmin=true could never repair its slot map.
RefreshTopologyAsync re-reads CLUSTER SLOTS through the published endpoint
executor, whose Validate classes CLUSTER as admin - so every refresh refused
itself, and recovery depended on a -MOVED winning the race against the demoted
node's role being learned. Once the role landed first, writes were refused
client-side before any byte went out, so no redirect ever arrived to mark the
map suspect: stale map + fresh role = refused forever, exactly the deadlock
RespTopology.RoleLearned's remarks describe.

- Validate exempts requests carrying CommandFlagsInternal.InternalCallFlag from
  the AllowAdmin gate: the gate protects the caller-facing surface, and the
  handshake already escapes it by running on the bare connection (the same
  exemption the v3 handshake got from SetInternalCall). The flag had no
  consumers in v4, so this is v3 parity rather than new semantics.
- RefreshTopologyAsync's CLUSTER SLOTS and ROLE reads, and TryRefreshRole's
  ROLE, now carry that flag.
- The primary-only-refused-on-a-replica path tells the manager
  (OnWriteRefusedByRole): slot map suspect + ReconfigureIfNeeded, one
  reconfiguration per suspicion cycle, mirroring the unroutable-redirect path.

The new tests connect with AllowAdmin=false, which is how both halves hid from
every existing test: InProcessTestServer's client config defaults it to true,
so the refresh always repaired the map before the deadlock could be seen.

* A failover's write outage is detection-bounded, not ConnectTimeout-bounded: reconfigure when an established connection is lost, and read the topology before waiting on stragglers

The #3323 harness measured ~30s of failed writes on a failed-over shard where
2.x/3.x take ~7s, in two pieces:

- Nothing happened when the established connection died. RecordFault passed
  reconfigure: false always, so the first topology re-read waited for the
  THIRD consecutive dial failure (~15s later). The v3 bridge's contract was
  reconfigureNextFailure: armed only in OnFullyEstablished, consumed on the
  next failure - so an established connection's loss asks for one
  reconfiguration, while dial failures still don't (OnRepeatedConnectFailure
  keeps judging those, rate-limited; a lost established connection cannot
  loop, because it has to be re-established before it can be lost again).
  _reconfigureOnFailure restores that contract.
- The pass that then ran spent its whole wait dialling the node that is gone:
  RefreshTopologyAsync ran after WaitAllIgnoreErrorsAsync, so the healthy
  nodes were not asked where the slots went until ConnectTimeout expired.
  The refresh only uses connections that are already live, so it now runs
  before the wait; a pass that starts with nothing live still gets its answer
  from the handshakes the wait completes (a first connection asks CLUSTER
  SLOTS itself when there is no map or it is suspect), and the cluster second
  iteration refreshes again over whatever iteration one established.

LosingAnEstablishedConnectionPromptsATopologyRefresh pins the trigger: a
QUIET client (nothing in flight to redirect, nothing to refuse), the promoted-
away node refusing reconnects (so no restore-time reconfigure), AllowAdmin
off. Red at the poll timeout without both halves; the harness measures the
outage at 9.0s against a primary held dark for 90s (v2/v3: 7.0s, with the
rest being frozen-socket detection that a real failover's socket close does
not incur).
…3325)

A configuration channel makes the dedicated subscription connection part
of connecting (#3310): ActivateServer dials it eagerly, but the dial is
fire-and-forget by design, and the reconfigure connect wait covered the
interactive leg alone - so on a loaded machine ConnectAsync returned
while the library's own broadcast channel was still dialling.
ConfigurationChannelOpensSubscriptionConnectionEagerly (merged with the
3.4.29 sync) fails exactly there on CI, on both OSes, while fast local
machines win the race.

The wait itself broadens: when RequiresEagerSubscriptionConnection, the
endpoint's availability task also awaits the subscription executor's
ConnectNowAsync (short-circuits when up, joins the dial in flight,
declines onto a completed task under backoff, so the wait stays bounded
by the pass's budget), with a subscription-leg failure deliberately not
faulting the endpoint's availability - the interactive leg decides
selectability, and a refused subscription dial redials on demand.

Deliberately NOT by gating the connect monitors on the second leg, which
is how the v3 core does it (OnFullyEstablished): those monitors complete
on every reconnect, and gating them there re-times subscription
re-placement against one-shot publishes - Resp3DowngradeTests'
SubscribeQueuedBeforeDowngradeDoesNotPoisonInteractive fails
deterministically with the PUBLISH answered :0, the re-SUBSCRIBE a beat
behind it. The monitors keep their single-leg meaning; only the
reconfigure wait, which is what ConnectAsync means by connected, grows
the second leg.

The new test pins the race deterministically: an in-process server that
is slow to accept only the subscription leg (OnAcceptClientAsync now
has an overload seeing the declared connection type) must delay
ConnectAsync, not be escaped by it. Red on unfixed source with the CI
failure's exact shape; green with the fix, both protocols.

This branch was successfully deployed

1 active deployment
release — 6db1a419 Deployed Oct 31, 2026 by mgravell via publish #23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants